EKÁER: unify consignment mapping & obligation logic

- Introduced EkaerConsignment model for direction-agnostic mapping (inbound/outbound), centralizing normalization and aggregation.
- Refactored IShippingToEkaerMapper and service interfaces to use new model and expose obligation evaluation.
- Added EkaerReportability logic for robust, threshold-based reporting obligation checks with error handling.
- Updated UI and SignalR to support detailed creation results and user feedback for skipped entries.
- Enhanced tests and documentation to cover new mapping, evaluation, and legal context.
- Minor config/protocol adjustments for improved reliability.
This commit is contained in:
Loretta 2026-06-15 11:53:05 +02:00
parent 6f46aaebb0
commit c722a7b242
18 changed files with 637 additions and 118 deletions

View File

@ -69,7 +69,11 @@
"Bash(grep -c -a \"EurHufRate\" FruitBank.Common.dll FruitBank.Common.Server.dll)", "Bash(grep -c -a \"EurHufRate\" FruitBank.Common.dll FruitBank.Common.Server.dll)",
"Bash(ls -la --time-style=+%H:%M FruitBank.Common.dll FruitBank.Common.Server.dll)", "Bash(ls -la --time-style=+%H:%M FruitBank.Common.dll FruitBank.Common.Server.dll)",
"Bash(find \"H:\\\\\\\\Applications\\\\\\\\Mango\" -name \"Order.cs\" -path \"*/Domain/Orders/*\" 2>&1 | head -3)", "Bash(find \"H:\\\\\\\\Applications\\\\\\\\Mango\" -name \"Order.cs\" -path \"*/Domain/Orders/*\" 2>&1 | head -3)",
"Bash(f=/h/Applications/Aycode/Source/AyCode.Blazor/AyCode.Blazor.Components/Components/Grids/MgGridBase.razor; echo \"FILE exists: $\\(test -f $f && echo yes\\)\"; echo \"=== DxGrid nyitó tag + környéke ===\"; grep -n \"DxGrid\\\\|CustomizeDataRowFilter\\\\|CustomizeElement\\\\|@attributes\\\\|CustomizeEditModel\" \"$f\" 2>/dev/null | head -15)" "Bash(f=/h/Applications/Aycode/Source/AyCode.Blazor/AyCode.Blazor.Components/Components/Grids/MgGridBase.razor; echo \"FILE exists: $\\(test -f $f && echo yes\\)\"; echo \"=== DxGrid nyitó tag + környéke ===\"; grep -n \"DxGrid\\\\|CustomizeDataRowFilter\\\\|CustomizeElement\\\\|@attributes\\\\|CustomizeEditModel\" \"$f\" 2>/dev/null | head -15)",
"WebFetch(domain:www.ekaer-feladas.hu)",
"WebFetch(domain:net.jogtar.hu)",
"WebFetch(domain:www.itrack.hu)",
"WebFetch(domain:docplayer.hu)"
] ]
} }
} }

View File

@ -56,6 +56,12 @@ public sealed class FruitBankEkaerService : IFruitBankEkaerService
return TryConfigError(ekaerHistory, currency) ?? Finalize(ekaerHistory, _mapper.MapOrder(order, _settings.Company), currency); return TryConfigError(ekaerHistory, currency) ?? Finalize(ekaerHistory, _mapper.MapOrder(order, _settings.Company), currency);
} }
public EkaerObligationResult EvaluateObligation(IReadOnlyCollection<ShippingDocument> documents)
=> EkaerReportability.Evaluate(_mapper.ToConsignment(documents, _settings.Company), _settings);
public EkaerObligationResult EvaluateObligation(OrderDto order)
=> EkaerReportability.Evaluate(_mapper.ToConsignment(order, _settings.Company), _settings);
/// <summary>Config-kapu: külföldi (nem HUF) feladónál az árfolyam kötelező — különben a leképezés ELŐTT /// <summary>Config-kapu: külföldi (nem HUF) feladónál az árfolyam kötelező — különben a leképezés ELŐTT
/// ValidationError (nincs félrevezető XML). <c>null</c> = rendben, mehet a generálás.</summary> /// ValidationError (nincs félrevezető XML). <c>null</c> = rendben, mehet a generálás.</summary>
private EkaerHistory? TryConfigError(EkaerHistory ekaerHistory, string? currency) private EkaerHistory? TryConfigError(EkaerHistory ekaerHistory, string? currency)

View File

@ -2,6 +2,7 @@ using AyCode.Services.Nav.Ekaer;
using AyCode.Services.Nav.Ekaer.Models; using AyCode.Services.Nav.Ekaer.Models;
using FruitBank.Common.Dtos; using FruitBank.Common.Dtos;
using FruitBank.Common.Entities; using FruitBank.Common.Entities;
using FruitBank.Common.Services.Ekaer;
namespace FruitBank.Common.Server.Services.Ekaer; namespace FruitBank.Common.Server.Services.Ekaer;
@ -34,4 +35,14 @@ public interface IFruitBankEkaerService
/// ELADÓ, a vevő a CÍMZETT (belföldi értékesítés). NEM perzisztál és NEM hív NAV-ot — a mentés a hívó dolga. /// ELADÓ, a vevő a CÍMZETT (belföldi értékesítés). NEM perzisztál és NEM hív NAV-ot — a mentés a hívó dolga.
/// </summary> /// </summary>
EkaerHistory GenerateEkaerXmlDocument(OrderDto order, EkaerHistory? ekaerHistory = null); EkaerHistory GenerateEkaerXmlDocument(OrderDto order, EkaerHistory? ekaerHistory = null);
/// <summary>
/// Eldönti egy bejövő (Shipping, Partner) CSOPORT EKÁER-kötelezettségét: külföldi (a feladó és a címzett
/// országkódja eltér) → mindig kötelező; belföld → az AGGREGÁLT tömeg/érték a küszöbhöz; érvénytelen országkód →
/// <see cref="EkaerObligation.DataError"/>. A küszöb-summa a csoport ÖSSZES dokumentumának tételeire megy.
/// </summary>
EkaerObligationResult EvaluateObligation(IReadOnlyCollection<ShippingDocument> documents);
/// <summary>Eldönti egy kimenő rendelés EKÁER-kötelezettségét (ugyanaz a logika, a rendelés tételeire).</summary>
EkaerObligationResult EvaluateObligation(OrderDto order);
} }

View File

@ -0,0 +1,13 @@
using AyCode.Core.Serializers.Attributes;
namespace FruitBank.Common.Dtos;
/// <summary>A „hiányzó EKÁER-sorok létrehozása" (reconciliation gomb) eredménye a SignalR-dróton: hány Pending sor
/// jött létre, és a felhasználónak szóló sima string üzenetek (adathiány / formátumhiba miatt kihagyott jelöltek —
/// pl. érvénytelen országkód). A küszöb alatti, nem kötelező jelölteknél NINCS üzenet. A megjelenítés a hívó dolga.</summary>
[AcBinarySerializable(false, false, false, false, false, false)]
public sealed class EkaerCreateResult
{
public int CreatedCount { get; set; }
public List<string> Messages { get; set; } = [];
}

View File

@ -36,7 +36,7 @@ public interface IFruitBankDataControllerCommon
public Task<EkaerHistory?> UpdateEkaerHistory(EkaerHistory ekaerHistory); public Task<EkaerHistory?> UpdateEkaerHistory(EkaerHistory ekaerHistory);
public Task<EkaerHistory?> GenerateEkaerXmlDocument(int foreignKey, bool isOutgoing); public Task<EkaerHistory?> GenerateEkaerXmlDocument(int foreignKey, bool isOutgoing);
public Task<EkaerHistory?> CreateEkaerHistory(int foreignKey, bool isOutgoing); public Task<EkaerHistory?> CreateEkaerHistory(int foreignKey, bool isOutgoing);
public Task<int> CreateMissingEkaerHistories(DateTime fromDate); public Task<EkaerCreateResult?> CreateMissingEkaerHistories(DateTime fromDate);
public Task<int> GetEkaerHistoryCount(EkaerHistoryFilter filter); public Task<int> GetEkaerHistoryCount(EkaerHistoryFilter filter);
#endregion EkaerHistory #endregion EkaerHistory

View File

@ -0,0 +1,70 @@
using AyCode.Services.Nav.Ekaer.Models;
using TradeReasonType = AyCode.Services.Nav.Ekaer.Models.Common.TradeReasonType;
namespace FruitBank.Common.Services.Ekaer;
/// <summary>
/// Irány-független, normalizált szállítmány-modell. A bejövő (<c>ShippingDocument</c>-csoport) és a kimenő
/// (<c>OrderDto</c>) forrás EBBE képződik (a <see cref="IShippingToEkaerMapper"/> adapterei), és innen épül MIND a
/// NAV tradeCard (<see cref="IShippingToEkaerMapper.BuildTradeCard"/>), MIND a bejelentés-kötelezettség
/// (<see cref="EkaerReportability"/>). Így az irányfüggő tudás a két adapterre szorul, a közös logika egy helyen van.
/// Szerver-oldali köztes típus — NEM megy a SignalR-dróton.
/// </summary>
/// <remarks>
/// A fel-/lerakodási hely és a jármű már a NAV-típus (<see cref="LocationType"/>, <see cref="BasicVehicleDetailType"/>),
/// mert a saját telephely (<c>company.Site</c>) kész LocationType (Phone/Email/FELIR-mezőkkel) — azt átalakítás nélkül,
/// MEZŐVESZTÉS nélkül kell átengedni. A normalizálás (kötelezettséghez számít) a feladó/címzett országkódjára és a
/// tételek tömeg/érték-aggregálására korlátozódik.
/// </remarks>
public sealed class EkaerConsignment
{
/// <summary>A forrás azonosítója (bejövőnél ShippingDocument.Id, kimenőnél Order.Id). Csoport-kiértékelésnél
/// (több dokumentum) az első forrásé — a kötelezettség-döntés nem használja.</summary>
public int ForeignKey { get; init; }
public bool IsOutgoing { get; init; }
/// <summary>Számla-pénznem (ISO 4217). A tétel <see cref="EkaerLine.ValueHuf"/> már KISZÁMOLT HUF-ban.</summary>
public string? Currency { get; init; }
/// <summary>Feladó / eladó (a kötelezettség az országkódját nézi).</summary>
public EkaerEndpoint Seller { get; init; } = new();
/// <summary>Címzett / vevő (a kötelezettség az országkódját nézi).</summary>
public EkaerEndpoint Buyer { get; init; } = new();
/// <summary>Felrakodási hely (NAV LocationType — pl. a saját telephely átengedve).</summary>
public LocationType? LoadLocation { get; init; }
/// <summary>Lerakodási hely (NAV LocationType — pl. a saját telephely átengedve).</summary>
public LocationType? UnloadLocation { get; init; }
public IReadOnlyList<EkaerLine> Lines { get; init; } = [];
public BasicVehicleDetailType? Vehicle { get; init; }
public BasicVehicleDetailType? Trailer { get; init; }
public string? CarrierName { get; init; }
}
/// <summary>Egy fél (feladó/címzett) normalizálatlan adatai a NAV seller/destination mezőkhöz ÉS a kötelezettség
/// országkód-vizsgálatához. (A cím egysoros; a tagolt fel-/lerakodási helyet a <see cref="EkaerConsignment.LoadLocation"/>
/// / <see cref="EkaerConsignment.UnloadLocation"/> hordozza.)</summary>
public sealed class EkaerEndpoint
{
public string? Name { get; init; }
public string? VatNumber { get; init; }
public string? CountryCode { get; init; }
public string? FullAddress { get; init; }
}
/// <summary>Egy normalizált tétel. A <see cref="WeightKg"/> és a <see cref="ValueHuf"/> már KISZÁMOLT (HUF-ban),
/// hogy a küszöb-summa és a tradeCard ugyanazt használja (egyetlen érték-forrás).</summary>
public sealed class EkaerLine
{
public string ExternalId { get; init; } = string.Empty;
public string? Vtsz { get; init; }
public string? Name { get; init; }
public double WeightKg { get; init; }
public long? ValueHuf { get; init; }
public TradeReasonType TradeReason { get; init; }
}

View File

@ -0,0 +1,83 @@
using AyCode.Services.Nav.Ekaer;
namespace FruitBank.Common.Services.Ekaer;
/// <summary>Egy szállítmány EKÁER bejelentés-kötelezettsége.</summary>
public enum EkaerObligation
{
/// <summary>Kötelező bejelenteni (sort kell létrehozni).</summary>
Required,
/// <summary>Nem kötelező (belföld, küszöb alatt) — NEM hiba, nincs üzenet.</summary>
NotRequired,
/// <summary>A döntés nem hozható meg adathiány/formátumhiba miatt (pl. érvénytelen országkód) — NINCS sor, az okok az üzenetekben.</summary>
DataError,
}
/// <summary>A kötelezettség-kiértékelés eredménye: a döntés + (csak DataError esetén) a felhasználónak szóló okok.</summary>
public sealed class EkaerObligationResult
{
public EkaerObligation Obligation { get; private init; }
public IReadOnlyList<string> Errors { get; private init; } = [];
public static EkaerObligationResult Required { get; } = new() { Obligation = EkaerObligation.Required };
public static EkaerObligationResult NotRequired { get; } = new() { Obligation = EkaerObligation.NotRequired };
public static EkaerObligationResult DataError(IReadOnlyList<string> errors) =>
new() { Obligation = EkaerObligation.DataError, Errors = errors };
}
/// <summary>
/// EKÁER bejelentés-kötelezettség eldöntése egy normalizált <see cref="EkaerConsignment"/>-re — KÖZÖS a bejövő
/// (document-csoport) és a kimenő (order) ágon. A NAV mindkét irányban büntet (a felesleges bejelentés ÉS a kimaradás
/// is bírság), ezért: a bizonytalan adatot (érvénytelen országkód) NEM döntjük el magától → <see cref="EkaerObligation.DataError"/>.
/// </summary>
/// <remarks>
/// Sorrend: (1) az AGGREGÁLT tömeg/érték küszöb FELETT → Required, az országkódtól FÜGGETLENÜL — belföld+küszöb
/// felett és külföld egyaránt kötelező; az országkód-hibát ilyenkor a generate-validálás jelzi, a sor létrejön;
/// (2) küszöb ALATT → itt számít az ország: hiányzó/érvénytelen országkód → DataError (a foreign-vs-belföld nem
/// dönthető el); (3) küszöb alatt, érvényes országkódok: külföld (eltér) → Required, belföld (egyezik) → NotRequired.
/// A 13/2020. (XII. 23.) PM rendelet alapján a küszöb feladó→címzett→jármű relációra aggregált — a hívó már
/// így állítja össze a <see cref="EkaerConsignment.Lines"/>-t (bejövőnél a (Shipping, Partner) csoport tételei).
/// </remarks>
public static class EkaerReportability
{
public static EkaerObligationResult Evaluate(EkaerConsignment consignment, IEkaerSettings settings)
{
ArgumentNullException.ThrowIfNull(consignment);
ArgumentNullException.ThrowIfNull(settings);
// (1) Küszöb FELETT → kötelező, FÜGGETLENÜL az országkódtól: belföld+küszöb felett ÉS külföld egyaránt kötelező.
// Az országkód-hibát ilyenkor NEM itt blokkoljuk — a sor létrejön, a hibát a generate-validálás jelzi.
var totalWeight = consignment.Lines.Sum(l => l.WeightKg);
var totalValueHuf = consignment.Lines.Sum(l => l.ValueHuf ?? 0L);
if (totalWeight >= settings.ThresholdWeightKg || totalValueHuf >= settings.ThresholdValueHuf)
return EkaerObligationResult.Required;
// (2) Küszöb ALATT: itt MÁR az ország dönt — csak a külföldi (eltérő országkód) reláció kötelező. Ehhez érvényes
// ISO-2 országkódok kellenek; hiányzó/érvénytelen → a foreign-vs-belföld nem dönthető el → DataError.
var subject = Subject(consignment);
var errors = new List<string>();
if (!IsValidCountry(consignment.Seller.CountryCode))
errors.Add($"{subject}: a feladó országkódja hiányzik vagy érvénytelen ('{consignment.Seller.CountryCode}').");
if (!IsValidCountry(consignment.Buyer.CountryCode))
errors.Add($"{subject}: a címzett országkódja hiányzik vagy érvénytelen ('{consignment.Buyer.CountryCode}').");
if (errors.Count > 0)
return EkaerObligationResult.DataError(errors);
// (3) Küszöb alatt, érvényes országkódok: külföld (eltér) → kötelező, belföld (egyezik) → nem kötelező (üzenet nélkül).
return CountryEquals(consignment.Seller.CountryCode, consignment.Buyer.CountryCode)
? EkaerObligationResult.NotRequired
: EkaerObligationResult.Required;
}
/// <summary>Érvényes EKÁER országkód: pontosan 2 ASCII betű (ISO-2). Üres / más hosszúságú → érvénytelen.</summary>
private static bool IsValidCountry(string? code)
{
var c = code?.Trim();
return c is { Length: 2 } && c.All(char.IsAsciiLetter);
}
private static bool CountryEquals(string? a, string? b) => string.Equals(a?.Trim(), b?.Trim(), StringComparison.OrdinalIgnoreCase);
private static string Subject(EkaerConsignment c)
=> c.IsOutgoing ? $"Rendelés #{c.ForeignKey}" : string.IsNullOrWhiteSpace(c.Seller.Name) ? $"Szállítólevél #{c.ForeignKey}" : c.Seller.Name!;
}

View File

@ -37,4 +37,17 @@ public interface IShippingToEkaerMapper
/// a kimenő fuvar-adat (OrderDto) — amíg az nincs bekötve, üresen marad (a validátor jelzi). /// a kimenő fuvar-adat (OrderDto) — amíg az nincs bekötve, üresen marad (a validátor jelzi).
/// </summary> /// </summary>
TradeCardType MapOrder(OrderDto order, EkaerCompanyInfo company); TradeCardType MapOrder(OrderDto order, EkaerCompanyInfo company);
/// <summary>
/// Bejövő forrás → normalizált <see cref="EkaerConsignment"/>. Egy vagy több <see cref="ShippingDocument"/>-tel
/// hívható: EGY dokumentummal a tradeCard-generáláshoz, a (Shipping, Partner) CSOPORTtal a kötelezettség-summához
/// (a tételek összevonva). A hívó gondoskodik róla, hogy a csoport azonos partneré és azonos Shippingé legyen.
/// </summary>
EkaerConsignment ToConsignment(IReadOnlyCollection<ShippingDocument> documents, EkaerCompanyInfo company);
/// <summary>Kimenő forrás → normalizált <see cref="EkaerConsignment"/> (egy rendelés = egy szállítmány).</summary>
EkaerConsignment ToConsignment(OrderDto order, EkaerCompanyInfo company);
/// <summary>Normalizált szállítmány → NAV tradeCard. KÖZÖS a két irányra; a <c>MapDocument</c>/<c>MapOrder</c> ezt hívja.</summary>
TradeCardType BuildTradeCard(EkaerConsignment consignment);
} }

View File

@ -12,17 +12,19 @@ namespace FruitBank.Common.Services.Ekaer;
/// <inheritdoc cref="IShippingToEkaerMapper"/> /// <inheritdoc cref="IShippingToEkaerMapper"/>
/// <remarks> /// <remarks>
/// Tiszta (állapotmentes) leképező. A feladót és a saját céget egységesen <see cref="ICompanyInfoBase"/>-ként kezeli. /// Tiszta (állapotmentes) leképező. A bejövő (<c>ShippingDocument</c>-csoport) és a kimenő (<c>OrderDto</c>) forrás
/// A <c>TradeType</c>/<c>TradeReasonType</c> enumokat aliasszal hozzuk be a <c>Models.TradeCardType</c> osztály /// előbb egy irány-független <see cref="EkaerConsignment"/>-re képződik (<see cref="ToConsignment(IReadOnlyCollection{ShippingDocument}, EkaerCompanyInfo)"/>
/// és a <c>Models.Common.TradeCardType</c> enum névütközése miatt. /// / <see cref="ToConsignment(OrderDto, EkaerCompanyInfo)"/>), majd EBBŐL épül a NAV tradeCard (<see cref="BuildTradeCard"/>).
/// Így az irányfüggő tudás a két adapterre szorul, a NAV-build közös. A fel-/lerakodási hely és a jármű már a NAV-típus
/// (a saját telephely kész <see cref="LocationType"/>, mezővesztés nélkül átengedve); a feladó/címzett normalizálása a
/// build-ben történik. A <c>TradeType</c>/<c>TradeReasonType</c> enumokat aliasszal hozzuk be a névütközés miatt.
/// </remarks> /// </remarks>
public sealed class ShippingToEkaerMapper : IShippingToEkaerMapper public sealed class ShippingToEkaerMapper : IShippingToEkaerMapper
{ {
/// <summary>A NAV EKÁER magyar rendszer — a „belföld" mindig HU; minden más feladó-ország import.</summary> /// <summary>A NAV EKÁER magyar rendszer — a „belföld" alapértéke HU (a vevő-ország feloldásáig).</summary>
private const string HomeCountry = "HU"; private const string HomeCountry = "HU";
/// <summary>Kimenő pénznem — jelenleg minden HUF (a vevő/rendelés devizája az OrderDto-ban még nincs leképezve; /// <summary>Kimenő pénznem — jelenleg minden HUF (a vevő/rendelés devizája az OrderDto-ban még nincs leképezve).</summary>
/// bekötéskor innen jön). HUF → nincs átváltás (rate 1).</summary>
private const string OutboundCurrency = "HUF"; private const string OutboundCurrency = "HUF";
private readonly IEkaerSettings _settings; private readonly IEkaerSettings _settings;
@ -30,6 +32,8 @@ public sealed class ShippingToEkaerMapper : IShippingToEkaerMapper
public ShippingToEkaerMapper(IEkaerSettings settings) public ShippingToEkaerMapper(IEkaerSettings settings)
=> _settings = settings ?? throw new ArgumentNullException(nameof(settings)); => _settings = settings ?? throw new ArgumentNullException(nameof(settings));
// ── Belépési pontok (vékony wrapperek a köztes modell + a közös build köré) ───────────────────
public IReadOnlyList<TradeCardOperationType> MapShipping(Shipping shipping, EkaerCompanyInfo company, OperationType operation = OperationType.Create) public IReadOnlyList<TradeCardOperationType> MapShipping(Shipping shipping, EkaerCompanyInfo company, OperationType operation = OperationType.Create)
{ {
ArgumentNullException.ThrowIfNull(shipping); ArgumentNullException.ThrowIfNull(shipping);
@ -41,13 +45,13 @@ public sealed class ShippingToEkaerMapper : IShippingToEkaerMapper
// Granularitás: egy ShippingDocument → egy tradeCard (lásd EKAER_TODO #5). // Granularitás: egy ShippingDocument → egy tradeCard (lásd EKAER_TODO #5).
foreach (var document in shipping.ShippingDocuments ?? []) foreach (var document in shipping.ShippingDocuments ?? [])
{ {
document.Shipping ??= shipping; // a vontató/fuvarozó a Shippingről jön
index++; index++;
var rateToHuf = EkaerValueCalculator.ResolveRateToHuf(document.Partner?.Currency, _settings.EurHufRate);
operations.Add(new TradeCardOperationType operations.Add(new TradeCardOperationType
{ {
Index = index, Index = index,
Operation = operation, Operation = operation,
TradeCard = BuildTradeCard(shipping, document, company, rateToHuf), TradeCard = BuildTradeCard(ToConsignment([document], company)),
}); });
} }
@ -58,130 +62,184 @@ public sealed class ShippingToEkaerMapper : IShippingToEkaerMapper
{ {
ArgumentNullException.ThrowIfNull(document); ArgumentNullException.ThrowIfNull(document);
ArgumentNullException.ThrowIfNull(company); ArgumentNullException.ThrowIfNull(company);
return BuildTradeCard(ToConsignment([document], company));
var rateToHuf = EkaerValueCalculator.ResolveRateToHuf(document.Partner?.Currency, _settings.EurHufRate);
return BuildTradeCard(document.Shipping, document, company, rateToHuf);
} }
/// <inheritdoc />
public TradeCardType MapOrder(OrderDto order, EkaerCompanyInfo company) public TradeCardType MapOrder(OrderDto order, EkaerCompanyInfo company)
{
ArgumentNullException.ThrowIfNull(order);
ArgumentNullException.ThrowIfNull(company);
return BuildTradeCard(ToConsignment(order, company));
}
// ── Adapterek: forrás → normalizált EkaerConsignment (az EGYETLEN irányfüggő rész) ────────────
/// <summary>Bejövő: egy vagy több <c>ShippingDocument</c> (a hívó (Shipping, Partner)-re csoportosít) → egy
/// szállítmány a csoport ÖSSZES tételével. Egy dokumentummal a generáláshoz, a csoporttal a kötelezettség-summához.</summary>
public EkaerConsignment ToConsignment(IReadOnlyCollection<ShippingDocument> documents, EkaerCompanyInfo company)
{
ArgumentNullException.ThrowIfNull(documents);
ArgumentNullException.ThrowIfNull(company);
if (documents.Count == 0) throw new ArgumentException("Legalább egy ShippingDocument szükséges.", nameof(documents));
var first = documents.First();
var partner = first.Partner; // a csoport azonos partneré (a hívó (Shipping, Partner)-re csoportosít)
var shipping = first.Shipping; // azonos Shipping; a kapunál lehet null (a jármű ott nem kell)
var rateToHuf = SafeRateToHuf(partner?.Currency);
var lines = documents
.SelectMany(d => d.ShippingItems ?? [])
.Select(item => new EkaerLine
{
ExternalId = item.Id.ToString(),
TradeReason = TradeReasonType.A, // bejövő áru = beszerzés
Vtsz = NormalizeVtsz(item.ProductDto?.Gtin),
Name = item.ProductName,
WeightKg = item.MeasuredGrossWeight,
ValueHuf = EkaerValueCalculator.ItemValueHuf(item, rateToHuf),
})
.ToList();
return new EkaerConsignment
{
ForeignKey = first.Id,
IsOutgoing = false,
Currency = partner?.Currency,
Seller = PartnerEndpoint(partner),
Buyer = CompanyEndpoint(company),
LoadLocation = BuildLocation(partner), // a beszállító telephelye (a PartnerDepot-bekötés külön feladat)
UnloadLocation = company.Site, // a saját telephely (kész LocationType — mezővesztés nélkül átengedve)
Lines = lines,
Vehicle = BuildVehicle(shipping?.CargoTruck),
Trailer = BuildVehicle(shipping?.CargoTrailer),
CarrierName = shipping?.CargoPartner?.Name,
};
}
/// <summary>Kimenő: egy rendelés → egy szállítmány. Nincs Shipping; a kötelezettséget a rendelésre nézzük.</summary>
public EkaerConsignment ToConsignment(OrderDto order, EkaerCompanyInfo company)
{ {
ArgumentNullException.ThrowIfNull(order); ArgumentNullException.ThrowIfNull(order);
ArgumentNullException.ThrowIfNull(company); ArgumentNullException.ThrowIfNull(company);
var customer = order.Customer; var customer = order.Customer;
var rateToHuf = EkaerValueCalculator.ResolveRateToHuf(OutboundCurrency, _settings.EurHufRate); var rateToHuf = SafeRateToHuf(OutboundCurrency); // jelenleg minden HUF
var tradeCard = new TradeCardType var lines = (order.OrderItemDtos ?? [])
.Select(item => new EkaerLine
{ {
// Kimenő belföldi értékesítés. (Export `E` + külföldi deviza: későbbi — jelenleg minden HUF/belföld.) ExternalId = item.Id.ToString(),
TradeType = TradeType.D, TradeReason = TradeReasonType.S, // kimenő áru = értékesítés
ModByCarrierEnabled = false, Vtsz = NormalizeVtsz(item.ProductDto?.Gtin),
Name = item.ProductDto?.Name,
WeightKg = item.GrossWeight,
ValueHuf = EkaerValueCalculator.ItemValueHuf(item, rateToHuf),
})
.ToList();
// Eladó = mi (kimenő relációban) return new EkaerConsignment
SellerName = company.Name, {
SellerVatNumber = NormalizeVatNumber(company.TaxId), ForeignKey = order.Id,
SellerCountry = NormalizeCountryCode(company.CountryCode, 2), IsOutgoing = true,
SellerAddress = Truncate(company.FullAddress, 200), Currency = OutboundCurrency,
Seller = CompanyEndpoint(company),
// Címzett = a vevő. Ország jelenleg HU (belföld); a Customer.CountryId-feloldás + export: későbbi. Buyer = CustomerEndpoint(customer),
DestinationName = customer?.Company,
DestinationVatNumber = NormalizeVatNumber(customer?.VatNumber),
DestinationCountry = HomeCountry,
DestinationAddress = Truncate(ComposeCustomerAddress(customer), 200),
// Felrakodás = a mi telephelyünk; lerakodás = a vevő.
LoadLocation = company.Site, LoadLocation = company.Site,
UnloadLocation = BuildCustomerLocation(customer), UnloadLocation = BuildCustomerLocation(customer),
Lines = lines,
// Vonó jármű / fuvarozó: a kimenő fuvar-adat (OrderDto) bekötéséig nincs forrás — üresen marad, // Kimenőnél a VEVŐ veszi át / viszi el az árut → ő a fuvarozó.
// a validátor jelzi (mint a bejövőnél kezdetben). TODO: order.<transport> → Vehicle / CarrierText. CarrierName = customer?.Company,
// A vonó jármű (rendszám) a customer-hez még nincs bekötve → üresen marad, a felrakodás megkezdéséig pótolandó
// (a validátor warningolja). Amint bekötik, a Vehicle is innen jön.
}; };
foreach (var item in order.OrderItemDtos ?? []) tradeCard.Items.Add(BuildOutboundItem(item, rateToHuf));
return tradeCard;
} }
private static TradeCardType BuildTradeCard(Shipping? shipping, ShippingDocument document, EkaerCompanyInfo company, double rateToHuf) // ── Közös build: normalizált szállítmány → NAV tradeCard (a korábbi két ág helyett egy) ───────
public TradeCardType BuildTradeCard(EkaerConsignment consignment)
{ {
var seller = document.Partner; // a beszállító (feladó) — ICompanyInfoBase ArgumentNullException.ThrowIfNull(consignment);
var tradeCard = new TradeCardType var tradeCard = new TradeCardType
{ {
TradeType = ResolveTradeType(seller), TradeType = ResolveTradeType(consignment),
ModByCarrierEnabled = false, // mi jelentünk; a fuvarozó alapból nem módosíthat ModByCarrierEnabled = false, // mi jelentünk; a fuvarozó alapból nem módosíthat
// Feladó / eladó = a beszállító SellerName = consignment.Seller.Name,
SellerName = seller?.Name, SellerVatNumber = NormalizeVatNumber(consignment.Seller.VatNumber),
SellerVatNumber = NormalizeVatNumber(seller?.TaxId), SellerCountry = NormalizeCountryCode(consignment.Seller.CountryCode, 2),
SellerCountry = NormalizeCountryCode(seller?.CountryCode, 2), SellerAddress = Truncate(consignment.Seller.FullAddress, 200),
SellerAddress = Truncate(seller?.FullAddress, 200),
// Címzett = a bejelentő saját cége (bejövő relációban) DestinationName = consignment.Buyer.Name,
DestinationName = company.Name, DestinationVatNumber = NormalizeVatNumber(consignment.Buyer.VatNumber),
DestinationVatNumber = NormalizeVatNumber(company.TaxId), DestinationCountry = NormalizeCountryCode(consignment.Buyer.CountryCode, 2),
DestinationCountry = NormalizeCountryCode(company.CountryCode, 2), DestinationAddress = Truncate(consignment.Buyer.FullAddress, 200),
DestinationAddress = Truncate(company.FullAddress, 200),
// Fuvarozó (Shipping.CargoPartner). Regisztrált EKAER-azonosító nincs, csak szöveges név. CarrierText = consignment.CarrierName,
CarrierText = shipping?.CargoPartner?.Name,
// Lerakodás = saját telephely (a cégadatból); felrakodás = a beszállító telephelye. // A helyek kész NAV LocationType-ok (a saját telephely érintetlenül) — átengedve.
UnloadLocation = company.Site, LoadLocation = consignment.LoadLocation,
LoadLocation = BuildLoadLocation(seller), UnloadLocation = consignment.UnloadLocation,
}; };
// Vonó jármű + vontatmány: az EKÁER külön bejegyzésként kéri (vehicle / vehicle2). if (consignment.Vehicle != null) tradeCard.Vehicle = consignment.Vehicle;
if (shipping?.CargoTruck != null) tradeCard.Vehicle = BuildVehicle(shipping.CargoTruck); if (consignment.Trailer != null) tradeCard.Vehicle2 = consignment.Trailer;
if (shipping?.CargoTrailer != null) tradeCard.Vehicle2 = BuildVehicle(shipping.CargoTrailer);
foreach (var item in document.ShippingItems ?? []) tradeCard.Items.Add(BuildItem(item, rateToHuf)); foreach (var line in consignment.Lines) tradeCard.Items.Add(BuildItem(line));
return tradeCard; return tradeCard;
} }
/// <summary> /// <summary>Belföld (a feladó és a címzett országkódja megegyezik) → <c>D</c>; eltérő országok → kimenőnél
/// Belföldi feladó (HU) → <c>D</c> (belföld-belföld), egyébként → <c>I</c> (import). A NAV EKÁER magyar, /// export (<c>E</c>), bejövőnél import (<c>I</c>).</summary>
/// így a belföld mindig HU; az export (<c>E</c>) jelenleg nincs leképezve (lásd EKAER_TODO #1, #7). private static TradeType ResolveTradeType(EkaerConsignment c)
/// </summary>
private static TradeType ResolveTradeType(ICompanyInfoBase? seller)
=> string.Equals(seller?.CountryCode, HomeCountry, StringComparison.OrdinalIgnoreCase)
? TradeType.D
: TradeType.I;
private static TradeCardItemType BuildItem(ShippingItem item, double rateToHuf) => new()
{ {
ItemExternalId = item.Id.ToString(), if (string.Equals(c.Seller.CountryCode?.Trim(), c.Buyer.CountryCode?.Trim(), StringComparison.OrdinalIgnoreCase))
// Bejövő áru = beszerzés → A. (Enum: S=értékesítés, A=beszerzés, W=bérmunka, O=egyéb.) Lásd EKAER_TODO #9. return TradeType.D;
TradeReason = TradeReasonType.A, return c.IsOutgoing ? TradeType.E : TradeType.I;
ProductVtsz = NormalizeVtsz(item.ProductDto?.Gtin), // VTSZ — átmenetileg a Gtin oszlopban (FBANKAPP-DMODEL-I-P6X4) }
ProductName = item.ProductName,
Weight = (decimal)item.MeasuredGrossWeight, // bruttó tömeg kg-ban (lásd EKAER_TODO #4) private static TradeCardItemType BuildItem(EkaerLine line) => new()
Value = EkaerValueCalculator.ItemValueHuf(item, rateToHuf), // beszerzési érték HUF-ban (Partner.Currency → árfolyam); 0/ismeretlen → null {
ItemExternalId = line.ExternalId,
TradeReason = line.TradeReason,
ProductVtsz = line.Vtsz, // már normalizált (az adapterben)
ProductName = line.Name,
Weight = (decimal)line.WeightKg,
Value = line.ValueHuf,
}; };
/// <summary>Kimenő (Order) tétel → tradeCardItem. Értékesítés (<c>S</c>); bruttó tömeg a palettákból; nettó érték HUF-ban.</summary> // ── Forrás → normalizálatlan végpont (feladó/címzett: név/adószám/ország/egysoros cím) ─────────
private static TradeCardItemType BuildOutboundItem(OrderItemDto item, double rateToHuf) => new()
private static EkaerEndpoint PartnerEndpoint(ICompanyInfoBase? partner) => new()
{ {
ItemExternalId = item.Id.ToString(), Name = partner?.Name,
// Kimenő áru = értékesítés → S. (Enum: S=értékesítés, A=beszerzés, W=bérmunka, O=egyéb.) VatNumber = partner?.TaxId,
TradeReason = TradeReasonType.S, CountryCode = partner?.CountryCode,
ProductVtsz = NormalizeVtsz(item.ProductDto?.Gtin), // VTSZ — átmenetileg a Gtin oszlopban (FBANKAPP-DMODEL-I-P6X4) FullAddress = partner?.FullAddress,
ProductName = item.ProductDto?.Name,
Weight = (decimal)item.GrossWeight, // bruttó tömeg kg-ban (OrderItemPallets összegéből)
Value = EkaerValueCalculator.ItemValueHuf(item, rateToHuf), // nettó értékesítési érték HUF-ban; 0/ismeretlen → null
}; };
private static BasicVehicleDetailType BuildVehicle(CargoTruck truck) => new() private static EkaerEndpoint CompanyEndpoint(EkaerCompanyInfo company) => new()
{ {
PlateNumber = NormalizePlateNumber(truck.LicencePlate), Name = company.Name,
Country = NormalizeCountryCode(truck.CountryCode, 3), VatNumber = company.TaxId,
CountryCode = company.CountryCode,
FullAddress = company.FullAddress,
}; };
/// <summary> /// <summary>A vevő mint fél. Ország jelenleg HU — a Customer.CountryId→ISO feloldás (export E) külön feladat.</summary>
/// Felrakodási hely a beszállító adataiból. (Magyar feladónál a NAV a Phone/Email-t is kéri, ami az private static EkaerEndpoint CustomerEndpoint(Customer? customer) => new()
/// entitásban nincs — lásd EKAER_TODO #6.) {
/// </summary> Name = customer?.Company,
private static LocationType? BuildLoadLocation(ICompanyInfoBase? seller) VatNumber = customer?.VatNumber,
CountryCode = HomeCountry,
FullAddress = ComposeCustomerAddress(customer),
};
// ── Forrás → NAV LocationType / jármű (a saját telephelyet az adapter közvetlenül adja) ────────
/// <summary>Felrakodási hely a beszállító adataiból (bejövő). A NAV magyar feladónál a Phone/Email-t is kéri, ami
/// az entitásban nincs — lásd EKAER_TODO #6.</summary>
private static LocationType? BuildLocation(ICompanyInfoBase? seller)
{ {
if (seller is null) return null; if (seller is null) return null;
return new LocationType return new LocationType
@ -195,16 +253,7 @@ public sealed class ShippingToEkaerMapper : IShippingToEkaerMapper
}; };
} }
/// <summary>A vevő egysoros címe (irsz + város + utca) a NAV <c>destinationAddress</c>-hez.</summary> /// <summary>Lerakodási hely a vevő adataiból (kimenő). Ország jelenleg HU (belföld).</summary>
private static string? ComposeCustomerAddress(Customer? customer)
{
if (customer is null) return null;
var parts = new[] { customer.ZipPostalCode, customer.City, customer.StreetAddress, customer.StreetAddress2 }
.Where(p => !string.IsNullOrWhiteSpace(p));
return EmptyToNull(string.Join(" ", parts).Trim());
}
/// <summary>Lerakodási hely a vevő adataiból (kimenő reláció). Ország jelenleg HU (belföld).</summary>
private static LocationType? BuildCustomerLocation(Customer? customer) private static LocationType? BuildCustomerLocation(Customer? customer)
{ {
if (customer is null) return null; if (customer is null) return null;
@ -219,6 +268,33 @@ public sealed class ShippingToEkaerMapper : IShippingToEkaerMapper
}; };
} }
private static BasicVehicleDetailType? BuildVehicle(CargoTruck? truck)
{
if (truck is null) return null;
return new BasicVehicleDetailType
{
PlateNumber = NormalizePlateNumber(truck.LicencePlate),
Country = NormalizeCountryCode(truck.CountryCode, 3),
};
}
/// <summary>A számla-pénznem → HUF szorzó, NEM dobó változat (a kapu külföldinél küszöb nélkül jelent, ott az érték
/// nem kell): HUF → 1; külföldi + érvényes árfolyam → árfolyam; külföldi + hiányzó árfolyam → 0 (a tétel-érték null lesz).
/// Generáláskor a service config-kapuja (TryConfigError) előbb elvágja a hiányzó-árfolyamos külföldi esetet.</summary>
private double SafeRateToHuf(string? currency)
=> EkaerValueCalculator.IsHuf(currency) ? 1d : (_settings.EurHufRate > 0 ? _settings.EurHufRate : 0d);
/// <summary>A vevő egysoros címe (irsz + város + utca) a NAV <c>destinationAddress</c>-hez.</summary>
private static string? ComposeCustomerAddress(Customer? customer)
{
if (customer is null) return null;
var parts = new[] { customer.ZipPostalCode, customer.City, customer.StreetAddress, customer.StreetAddress2 }
.Where(p => !string.IsNullOrWhiteSpace(p));
return EmptyToNull(string.Join(" ", parts).Trim());
}
// ── Normalizálók (NAV pattern-ek) ─────────────────────────────────────────────────────────────
/// <summary>Adószám normalizálása. Pattern: <c>[0-9A-Z-]{1,15}</c>.</summary> /// <summary>Adószám normalizálása. Pattern: <c>[0-9A-Z-]{1,15}</c>.</summary>
private static string? NormalizeVatNumber(string? value) private static string? NormalizeVatNumber(string? value)
{ {

View File

@ -0,0 +1,140 @@
using FruitBank.Common.Services.Ekaer;
using TradeReasonType = AyCode.Services.Nav.Ekaer.Models.Common.TradeReasonType;
namespace FruitBankHybrid.Shared.Tests.Ekaer;
/// <summary>
/// Unit tesztek a <see cref="EkaerReportability"/> bejelentés-kötelezettség-eldöntésére. Tisztán memóriában felépített
/// <see cref="EkaerConsignment"/>-eken fut (nincs hálózat/DB), determinisztikus. Küszöb: 200 kg / 250 000 Ft.
/// </summary>
[TestClass]
public sealed class EkaerReportabilityTests
{
private static readonly EkaerSettings Settings = new()
{
EurHufRate = 356,
ThresholdWeightKg = 200,
ThresholdValueHuf = 250_000,
};
private static EkaerConsignment Consignment(string? sellerCountry, string? buyerCountry, params EkaerLine[] lines) => new()
{
Seller = new EkaerEndpoint { Name = "Feladó", CountryCode = sellerCountry },
Buyer = new EkaerEndpoint { Name = "Címzett", CountryCode = buyerCountry },
Lines = lines,
};
private static EkaerLine Line(double weightKg, long? valueHuf) =>
new() { ExternalId = "1", WeightKg = weightKg, ValueHuf = valueHuf, TradeReason = TradeReasonType.A };
// ---- Belföld + küszöb ----------------------------------------------------
[TestMethod]
public void Domestic_BelowBothThresholds_NotRequired()
{
var result = EkaerReportability.Evaluate(Consignment("HU", "HU", Line(100, 100_000)), Settings);
Assert.AreEqual(EkaerObligation.NotRequired, result.Obligation);
Assert.AreEqual(0, result.Errors.Count, "küszöb alatt NINCS üzenet (nem hiba)");
}
[TestMethod]
public void Domestic_WeightAtOrOverThreshold_Required()
{
var result = EkaerReportability.Evaluate(Consignment("HU", "HU", Line(200, 0)), Settings);
Assert.AreEqual(EkaerObligation.Required, result.Obligation, "a tömeg eléri a küszöböt → kötelező (VAGY-logika)");
}
[TestMethod]
public void Domestic_ValueOverThreshold_Required()
{
var result = EkaerReportability.Evaluate(Consignment("HU", "HU", Line(10, 300_000)), Settings);
Assert.AreEqual(EkaerObligation.Required, result.Obligation, "az érték átlépi a küszöböt → kötelező");
}
[TestMethod]
public void Domestic_AggregatedOverThreshold_Required()
{
// Egyenként 200 kg ALATT, EGYÜTT fölötte → kötelező. Ez a (Shipping, Partner)-aggregálás lényege.
var result = EkaerReportability.Evaluate(Consignment("HU", "HU", Line(120, 0), Line(120, 0)), Settings);
Assert.AreEqual(EkaerObligation.Required, result.Obligation);
}
// ---- Külföld (a két országkód eltér) → küszöb nélkül kötelező ------------
[TestMethod]
public void CrossBorder_BelowThreshold_StillRequired()
{
var result = EkaerReportability.Evaluate(Consignment("DE", "HU", Line(1, 1)), Settings);
Assert.AreEqual(EkaerObligation.Required, result.Obligation, "eltérő országkód → mindig kötelező, küszöb nélkül");
}
[TestMethod]
public void SameForeignCountryBothEnds_TreatedAsDomesticThreshold()
{
// Mindkét vég azonos (nem HU) ország → NEM határátlépő → a küszöb dönt.
var result = EkaerReportability.Evaluate(Consignment("DE", "DE", Line(1, 1)), Settings);
Assert.AreEqual(EkaerObligation.NotRequired, result.Obligation);
}
// ---- Adathiba: érvénytelen/hiányzó országkód → DataError CSAK küszöb alatt ----
[TestMethod]
public void BelowThreshold_MissingSellerCountry_DataError()
{
// Küszöb alatt MÁR számít az ország (foreign-vs-belföld) — hiányzó országkód → nem dönthető el → DataError.
var result = EkaerReportability.Evaluate(Consignment(null, "HU", Line(10, 1000)), Settings);
Assert.AreEqual(EkaerObligation.DataError, result.Obligation);
Assert.IsTrue(result.Errors.Count > 0, "a hiányzó országkódot jelezni kell a felhasználónak");
}
[TestMethod]
public void BelowThreshold_InvalidSellerCountry_NotIso2_DataError()
{
// Teljes név (nem ISO-2) → érvénytelen → küszöb alatt nem dönthető el → DataError.
var result = EkaerReportability.Evaluate(Consignment("Magyarország", "HU", Line(10, 1000)), Settings);
Assert.AreEqual(EkaerObligation.DataError, result.Obligation);
}
[TestMethod]
public void BelowThreshold_MissingBuyerCountry_DataError()
{
var result = EkaerReportability.Evaluate(Consignment("HU", "", Line(10, 1000)), Settings);
Assert.AreEqual(EkaerObligation.DataError, result.Obligation);
}
[TestMethod]
public void OverThreshold_InvalidCountry_Required_NotDataError()
{
// Küszöb FELETT (tömeg 500 ≥ 200) a kötelezettség az országkódtól FÜGGETLEN → a sor létrejön (a hibát a
// generate-validálás jelzi), NEM DataError.
var result = EkaerReportability.Evaluate(Consignment(null, "Magyarország", Line(500, 0)), Settings);
Assert.AreEqual(EkaerObligation.Required, result.Obligation);
Assert.AreEqual(0, result.Errors.Count);
}
// ---- Hibás / üres adatok (robusztusság) ----------------------------------
[TestMethod]
public void NoLines_Domestic_NotRequired()
{
// Üres szállítmány (nincs tétel) → tömeg/érték 0 → küszöb alatt → belföld → nem kötelező (NEM dob).
var result = EkaerReportability.Evaluate(Consignment("HU", "HU"), Settings);
Assert.AreEqual(EkaerObligation.NotRequired, result.Obligation);
}
[TestMethod]
public void BothCountriesInvalid_BelowThreshold_DataError_WithTwoMessages()
{
var result = EkaerReportability.Evaluate(Consignment(null, "", Line(10, 1000)), Settings);
Assert.AreEqual(EkaerObligation.DataError, result.Obligation);
Assert.AreEqual(2, result.Errors.Count, "mindkét hibás országkódot külön jelezni kell");
}
[TestMethod]
public void NullLineValue_HandledAsZero_WeightDecides()
{
// Hiányzó (null) tétel-érték (pl. külföldi deviza árfolyam nélkül) → 0-ként számít, NEM dob; a tömeg dönt.
var result = EkaerReportability.Evaluate(Consignment("HU", "HU", Line(500, null)), Settings);
Assert.AreEqual(EkaerObligation.Required, result.Obligation, "a tömeg átlépi a küszöböt — a null érték nem akadály");
}
}

View File

@ -83,6 +83,28 @@ public sealed class ShippingToEkaerMapperTests
}, },
}; };
private static ShippingDocument CreateInboundDocument(int itemId, double weight, string sellerCountry = "HU") => new()
{
Country = sellerCountry,
Partner = new Partner
{
Name = "Beszállító Kft",
TaxId = "12345678-2-42",
CountryCode = sellerCountry,
PostalCode = "1011",
City = "Budapest",
Street = "Fő utca 1",
},
ShippingItems = [new ShippingItem
{
Id = itemId,
Name = "Alma",
ProductDto = new ProductDto { Gtin = "08081010", Name = "Alma" },
MeasuredGrossWeight = weight,
UnitPriceOnDocument = 5.0,
}],
};
// ---- Granularitás / index ---------------------------------------------- // ---- Granularitás / index ----------------------------------------------
[TestMethod] [TestMethod]
@ -216,4 +238,55 @@ public sealed class ShippingToEkaerMapperTests
[TestMethod] [TestMethod]
public void MapShipping_NullCompany_Throws() public void MapShipping_NullCompany_Throws()
=> Assert.ThrowsExactly<ArgumentNullException>(() => Mapper.MapShipping(CreateShipping(), null!)); => Assert.ThrowsExactly<ArgumentNullException>(() => Mapper.MapShipping(CreateShipping(), null!));
// ---- Köztes modell: ToConsignment (bejövő) ------------------------------
[TestMethod]
public void ToConsignment_Inbound_MultipleDocuments_CombinesLinesAndAggregatesWeight()
{
// Két szállítólevél (azonos partner, azonos Shipping) → egy szállítmány, MINDKÉT tétellel — a küszöb-aggregáláshoz.
var consignment = Mapper.ToConsignment(
[CreateInboundDocument(itemId: 1, weight: 100), CreateInboundDocument(itemId: 2, weight: 150)],
CreateCompany());
Assert.AreEqual(2, consignment.Lines.Count, "a csoport ÖSSZES tétele egy szállítmányban");
Assert.AreEqual(250d, consignment.Lines.Sum(l => l.WeightKg), 0.001, "a tömeg a dokumentumokból összegződik");
Assert.IsFalse(consignment.IsOutgoing);
Assert.AreEqual("HU", consignment.Seller.CountryCode, "feladó = a beszállító partner");
Assert.AreEqual("HU", consignment.Buyer.CountryCode, "címzett = a saját cég");
}
[TestMethod]
public void ToConsignment_Inbound_BuildTradeCard_PreservesCompanySiteAsUnloadLocation()
{
var company = CreateCompany();
var tradeCard = Mapper.BuildTradeCard(Mapper.ToConsignment([CreateInboundDocument(1, 100)], company));
Assert.AreSame(company.Site, tradeCard.UnloadLocation, "a saját telephely érintetlenül (mezővesztés nélkül) megy át");
}
[TestMethod]
public void ToConsignment_Inbound_EmptyDocuments_Throws()
=> Assert.ThrowsExactly<ArgumentException>(() => Mapper.ToConsignment([], CreateCompany()));
[TestMethod]
public void ToConsignment_Inbound_NullPartner_DoesNotThrow_SellerCountryNull()
{
// Hibás adat: nincs Partner a szállítólevélen → az adapter NEM dob; a feladó-ország null (ezt a kötelezettség-
// értékelő / generate-validálás kezeli, nem a leképező).
var doc = new ShippingDocument { Partner = null, ShippingItems = [new ShippingItem { Id = 1, MeasuredGrossWeight = 50 }] };
var consignment = Mapper.ToConsignment([doc], CreateCompany());
Assert.IsNull(consignment.Seller.CountryCode, "nincs partner → nincs feladó-ország");
Assert.AreEqual(1, consignment.Lines.Count);
}
[TestMethod]
public void ToConsignment_Inbound_NullShippingItems_EmptyLines()
{
// Hibás/hiányos adat: nincs tétel a szállítólevélen → üres Lines, NEM dob.
var doc = new ShippingDocument { Partner = new Partner { CountryCode = "HU" }, ShippingItems = null };
var consignment = Mapper.ToConsignment([doc], CreateCompany());
Assert.AreEqual(0, consignment.Lines.Count);
}
} }

View File

@ -5,14 +5,17 @@
@using AyCode.Services.Nav @using AyCode.Services.Nav
@using AyCode.Services.Nav.Ekaer.Models @using AyCode.Services.Nav.Ekaer.Models
@using AyCode.Utils.Extensions @using AyCode.Utils.Extensions
@using FruitBank.Common.Dtos
@using FruitBank.Common.Entities @using FruitBank.Common.Entities
@using FruitBankHybrid.Shared.Databases @using FruitBankHybrid.Shared.Databases
@using FruitBankHybrid.Shared.Extensions
@using FruitBankHybrid.Shared.Services.Loggers @using FruitBankHybrid.Shared.Services.Loggers
@using FruitBankHybrid.Shared.Services.SignalRs @using FruitBankHybrid.Shared.Services.SignalRs
@inject IEnumerable<IAcLogWriterClientBase> LogWriters @inject IEnumerable<IAcLogWriterClientBase> LogWriters
@inject FruitBankSignalRClient FruitBankSignalRClient @inject FruitBankSignalRClient FruitBankSignalRClient
@inject IJSRuntime JS @inject IJSRuntime JS
@inject IDialogService DialogService
<MgGridWithInfoPanel ShowInfoPanel="@IsMasterGrid"> <MgGridWithInfoPanel ShowInfoPanel="@IsMasterGrid">
<GridContent> <GridContent>
@ -147,6 +150,10 @@
/// hogy a fül-számlálók azonnal frissüljenek, mert a sor átkerülhet másik tabra.</summary> /// hogy a fül-számlálók azonnal frissüljenek, mert a sor átkerülhet másik tabra.</summary>
[Parameter] public EventCallback OnDataChanged { get; set; } [Parameter] public EventCallback OnDataChanged { get; set; }
/// <summary>A szülő (oldal) értesítése a hosszú művelet (sorok létrehozása) elejéről (true) / végéről (false) —
/// hogy az oldal Loading panelje megjelenjen/eltűnjön.</summary>
[Parameter] public EventCallback<bool> OnBusyChanged { get; set; }
public bool IsMasterGrid => ParentDataItem == null; public bool IsMasterGrid => ParentDataItem == null;
private LoggerClient<GridEkaerHistory> _logger; private LoggerClient<GridEkaerHistory> _logger;
@ -238,13 +245,23 @@
{ {
if (_creatingMissing) return; if (_creatingMissing) return;
_creatingMissing = true; _creatingMissing = true;
await OnBusyChanged.InvokeAsync(true); // Loading panel BE — a művelet lassú lehet (kimenő ág)
EkaerCreateResult? result = null;
try try
{ {
var createdCount = await FruitBankSignalRClient.CreateMissingEkaerHistories(CreateFromDate); result = await FruitBankSignalRClient.CreateMissingEkaerHistories(CreateFromDate);
_logger.Info($"CreateMissingEkaerHistories; created: {createdCount}; fromDate: {CreateFromDate:yyyy.MM.dd}"); var createdCount = result?.CreatedCount ?? 0;
_logger.Info($"CreateMissingEkaerHistories; created: {createdCount}; messages: {result?.Messages.Count ?? 0}; fromDate: {CreateFromDate:yyyy.MM.dd}");
if (createdCount > 0) await ReloadDataFromDb(true); foreach (var message in result?.Messages ?? [])
_logger.Warning($"CreateMissingEkaerHistories; skipped: {message}");
if (createdCount > 0)
{
await ReloadDataFromDb(true);
await NotifyDataChanged(); // a fül-számlálók frissítése (új sorok)
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -253,7 +270,12 @@
finally finally
{ {
_creatingMissing = false; _creatingMissing = false;
await OnBusyChanged.InvokeAsync(false); // Loading panel KI
} }
// Az adathiány/formátumhiba miatt KIHAGYOTT jelöltek üzenetei popupban — ezeket javítani kell, mielőtt bekerülhetnek.
if (result?.Messages is { Count: > 0 } skipped)
await DialogService.ShowMessageBoxAsync("EKÁER — kihagyott tételek", string.Join(Environment.NewLine, skipped), MessageBoxRenderStyle.Warning);
} }
private async Task OnCopyClick(EkaerHistory ekaerHistory) private async Task OnCopyClick(EkaerHistory ekaerHistory)

View File

@ -17,13 +17,13 @@
<DxTabs ActiveTabIndexChanged="(i) => OnActiveTabChanged(i)" RenderMode="TabsRenderMode.OnDemand" AllowTabReorder="false"> <DxTabs ActiveTabIndexChanged="(i) => OnActiveTabChanged(i)" RenderMode="TabsRenderMode.OnDemand" AllowTabReorder="false">
<DxTabPage Text="Beküldésre váró"> <DxTabPage Text="Beküldésre váró">
<GridEkaerHistory @ref="gridEkaerHistoryPending" Filter="EkaerHistoryFilter.ToSubmit" OnDataChanged="RefreshNeedsCompletionCountAsync"></GridEkaerHistory> <GridEkaerHistory @ref="gridEkaerHistoryPending" Filter="EkaerHistoryFilter.ToSubmit" OnDataChanged="RefreshNeedsCompletionCountAsync" OnBusyChanged="OnGridBusyChanged"></GridEkaerHistory>
</DxTabPage> </DxTabPage>
<DxTabPage Text="@NeedsCompletionTabText"> <DxTabPage Text="@NeedsCompletionTabText">
<GridEkaerHistory @ref="gridEkaerHistoryNeedsCompletion" Filter="EkaerHistoryFilter.ToSubmit" OnDataChanged="RefreshNeedsCompletionCountAsync"></GridEkaerHistory> <GridEkaerHistory @ref="gridEkaerHistoryNeedsCompletion" Filter="EkaerHistoryFilter.ToSubmit" OnDataChanged="RefreshNeedsCompletionCountAsync" OnBusyChanged="OnGridBusyChanged"></GridEkaerHistory>
</DxTabPage> </DxTabPage>
<DxTabPage Text="Elküldött"> <DxTabPage Text="Elküldött">
<GridEkaerHistory @ref="gridEkaerHistorySent" Filter="EkaerHistoryFilter.Sent" OnDataChanged="RefreshNeedsCompletionCountAsync"></GridEkaerHistory> <GridEkaerHistory @ref="gridEkaerHistorySent" Filter="EkaerHistoryFilter.Sent" OnDataChanged="RefreshNeedsCompletionCountAsync" OnBusyChanged="OnGridBusyChanged"></GridEkaerHistory>
</DxTabPage> </DxTabPage>
</DxTabs> </DxTabs>
</DxLoadingPanel> </DxLoadingPanel>

View File

@ -2,6 +2,7 @@ using AyCode.Core.Loggers;
using FruitBank.Common.Entities; using FruitBank.Common.Entities;
using FruitBank.Common.Models; using FruitBank.Common.Models;
using FruitBankHybrid.Shared.Components.Grids.Ekaers; using FruitBankHybrid.Shared.Components.Grids.Ekaers;
using FruitBankHybrid.Shared.Databases;
using FruitBankHybrid.Shared.Services.Loggers; using FruitBankHybrid.Shared.Services.Loggers;
using FruitBankHybrid.Shared.Services.SignalRs; using FruitBankHybrid.Shared.Services.SignalRs;
using Mango.Nop.Core.Loggers; using Mango.Nop.Core.Loggers;
@ -45,6 +46,13 @@ public partial class Ekaer : ComponentBase
await RefreshNeedsCompletionCountAsync(); await RefreshNeedsCompletionCountAsync();
} }
/// <summary>A grid jelzi a hosszú művelet (sorok létrehozása) elejét (true) / végét (false) → az oldal Loading panelje meg/elrejtve.</summary>
private Task OnGridBusyChanged(bool busy)
{
LoadingPanelVisibility.Visible = busy;
return InvokeAsync(StateHasChanged);
}
/// <summary>A „Pótlásra váró" rekordok számának frissítése a fül feliratához (a count endpointról, flag-szűrővel).</summary> /// <summary>A „Pótlásra váró" rekordok számának frissítése a fül feliratához (a count endpointról, flag-szűrővel).</summary>
private async Task RefreshNeedsCompletionCountAsync() private async Task RefreshNeedsCompletionCountAsync()
{ {

View File

@ -81,7 +81,7 @@ namespace FruitBankHybrid.Shared.Services.SignalRs
public Task<EkaerHistory?> UpdateEkaerHistory(EkaerHistory ekaerHistory) => PostDataAsync(SignalRTags.UpdateEkaerHistory, ekaerHistory); public Task<EkaerHistory?> UpdateEkaerHistory(EkaerHistory ekaerHistory) => PostDataAsync(SignalRTags.UpdateEkaerHistory, ekaerHistory);
public Task<EkaerHistory?> GenerateEkaerXmlDocument(int foreignKey, bool isOutgoing) => GetByIdAsync<EkaerHistory?>(SignalRTags.GenerateEkaerXmlDocument, [foreignKey, isOutgoing]); public Task<EkaerHistory?> GenerateEkaerXmlDocument(int foreignKey, bool isOutgoing) => GetByIdAsync<EkaerHistory?>(SignalRTags.GenerateEkaerXmlDocument, [foreignKey, isOutgoing]);
public Task<EkaerHistory?> CreateEkaerHistory(int foreignKey, bool isOutgoing) => GetByIdAsync<EkaerHistory?>(SignalRTags.CreateEkaerHistory, [foreignKey, isOutgoing]); public Task<EkaerHistory?> CreateEkaerHistory(int foreignKey, bool isOutgoing) => GetByIdAsync<EkaerHistory?>(SignalRTags.CreateEkaerHistory, [foreignKey, isOutgoing]);
public Task<int> CreateMissingEkaerHistories(DateTime fromDate) => GetByIdAsync<int>(SignalRTags.CreateMissingEkaerHistories, fromDate); public Task<EkaerCreateResult?> CreateMissingEkaerHistories(DateTime fromDate) => GetByIdAsync<EkaerCreateResult?>(SignalRTags.CreateMissingEkaerHistories, fromDate);
public Task<int> GetEkaerHistoryCount(EkaerHistoryFilter filter) => GetByIdAsync<int>(SignalRTags.GetEkaerHistoryCount, filter); public Task<int> GetEkaerHistoryCount(EkaerHistoryFilter filter) => GetByIdAsync<int>(SignalRTags.GetEkaerHistoryCount, filter);
#endregion EkaerHistory #endregion EkaerHistory

View File

@ -36,9 +36,9 @@
"UseStatefulReconnect": true "UseStatefulReconnect": true
}, },
"AcBinaryHubProtocol": { "AcBinaryHubProtocol": {
"ProtocolMode": "AsyncSegment", "ProtocolMode": "Bytes",
"BufferSize": 4096, "BufferSize": 4096,
"FlushPolicy": "Coalesced", "FlushPolicy": "Coalesced",
"FlushTimeout": "00:00:10" "FlushTimeout": "00:00:15"
} }
} }

View File

@ -36,9 +36,9 @@
"UseStatefulReconnect": true "UseStatefulReconnect": true
}, },
"AcBinaryHubProtocol": { "AcBinaryHubProtocol": {
"ProtocolMode": "AsyncSegment", "ProtocolMode": "Bytes",
"BufferSize": 4096, "BufferSize": 4096,
"FlushPolicy": "Coalesced", "FlushPolicy": "Coalesced",
"FlushTimeout": "00:00:10" "FlushTimeout": "00:00:15"
} }
} }

View File

@ -36,9 +36,9 @@
"UseStatefulReconnect": true "UseStatefulReconnect": true
}, },
"AcBinaryHubProtocol": { "AcBinaryHubProtocol": {
"ProtocolMode": "AsyncSegment", "ProtocolMode": "Bytes",
"BufferSize": 4096, "BufferSize": 4096,
"FlushPolicy": "Coalesced", "FlushPolicy": "Coalesced",
"FlushTimeout": "00:00:10" "FlushTimeout": "00:00:15"
} }
} }