From c722a7b242c1623efad94f10398698a4eafa67e1 Mon Sep 17 00:00:00 2001 From: Loretta Date: Mon, 15 Jun 2026 11:53:05 +0200 Subject: [PATCH] =?UTF-8?q?EK=C3=81ER:=20unify=20consignment=20mapping=20&?= =?UTF-8?q?=20obligation=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .claude/settings.local.json | 6 +- .../Services/Ekaer/FruitBankEkaerService.cs | 6 + .../Services/Ekaer/IFruitBankEkaerService.cs | 11 + FruitBank.Common/Dtos/EkaerCreateResult.cs | 13 + .../IFruitBankDataControllerCommon.cs | 2 +- .../Services/Ekaer/EkaerConsignment.cs | 70 +++++ .../Services/Ekaer/EkaerReportability.cs | 83 ++++++ .../Services/Ekaer/IShippingToEkaerMapper.cs | 13 + .../Services/Ekaer/ShippingToEkaerMapper.cs | 282 +++++++++++------- .../Ekaer/EkaerReportabilityTests.cs | 140 +++++++++ .../Ekaer/ShippingToEkaerMapperTests.cs | 73 +++++ .../Grids/Ekaers/GridEkaerHistory.razor | 28 +- FruitBankHybrid.Shared/Pages/Ekaer.razor | 6 +- FruitBankHybrid.Shared/Pages/Ekaer.razor.cs | 8 + .../SignalRs/FruitBankSignalRClient.cs | 2 +- FruitBankHybrid.Shared/appsettings.json | 4 +- .../wwwroot/appsettings.json | 4 +- FruitBankHybrid.Web/appsettings.json | 4 +- 18 files changed, 637 insertions(+), 118 deletions(-) create mode 100644 FruitBank.Common/Dtos/EkaerCreateResult.cs create mode 100644 FruitBank.Common/Services/Ekaer/EkaerConsignment.cs create mode 100644 FruitBank.Common/Services/Ekaer/EkaerReportability.cs create mode 100644 FruitBankHybrid.Shared.Tests/Ekaer/EkaerReportabilityTests.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e6cc8352..dc5d2c9a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -69,7 +69,11 @@ "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(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)" ] } } diff --git a/FruitBank.Common.Server/Services/Ekaer/FruitBankEkaerService.cs b/FruitBank.Common.Server/Services/Ekaer/FruitBankEkaerService.cs index 1f9334f0..f20e8d4e 100644 --- a/FruitBank.Common.Server/Services/Ekaer/FruitBankEkaerService.cs +++ b/FruitBank.Common.Server/Services/Ekaer/FruitBankEkaerService.cs @@ -56,6 +56,12 @@ public sealed class FruitBankEkaerService : IFruitBankEkaerService return TryConfigError(ekaerHistory, currency) ?? Finalize(ekaerHistory, _mapper.MapOrder(order, _settings.Company), currency); } + public EkaerObligationResult EvaluateObligation(IReadOnlyCollection documents) + => EkaerReportability.Evaluate(_mapper.ToConsignment(documents, _settings.Company), _settings); + + public EkaerObligationResult EvaluateObligation(OrderDto order) + => EkaerReportability.Evaluate(_mapper.ToConsignment(order, _settings.Company), _settings); + /// 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). null = rendben, mehet a generálás. private EkaerHistory? TryConfigError(EkaerHistory ekaerHistory, string? currency) diff --git a/FruitBank.Common.Server/Services/Ekaer/IFruitBankEkaerService.cs b/FruitBank.Common.Server/Services/Ekaer/IFruitBankEkaerService.cs index 22113025..604ce31e 100644 --- a/FruitBank.Common.Server/Services/Ekaer/IFruitBankEkaerService.cs +++ b/FruitBank.Common.Server/Services/Ekaer/IFruitBankEkaerService.cs @@ -2,6 +2,7 @@ using AyCode.Services.Nav.Ekaer; using AyCode.Services.Nav.Ekaer.Models; using FruitBank.Common.Dtos; using FruitBank.Common.Entities; +using FruitBank.Common.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. /// EkaerHistory GenerateEkaerXmlDocument(OrderDto order, EkaerHistory? ekaerHistory = null); + + /// + /// 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 → + /// . A küszöb-summa a csoport ÖSSZES dokumentumának tételeire megy. + /// + EkaerObligationResult EvaluateObligation(IReadOnlyCollection documents); + + /// Eldönti egy kimenő rendelés EKÁER-kötelezettségét (ugyanaz a logika, a rendelés tételeire). + EkaerObligationResult EvaluateObligation(OrderDto order); } diff --git a/FruitBank.Common/Dtos/EkaerCreateResult.cs b/FruitBank.Common/Dtos/EkaerCreateResult.cs new file mode 100644 index 00000000..ac4e02da --- /dev/null +++ b/FruitBank.Common/Dtos/EkaerCreateResult.cs @@ -0,0 +1,13 @@ +using AyCode.Core.Serializers.Attributes; + +namespace FruitBank.Common.Dtos; + +/// 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. +[AcBinarySerializable(false, false, false, false, false, false)] +public sealed class EkaerCreateResult +{ + public int CreatedCount { get; set; } + public List Messages { get; set; } = []; +} diff --git a/FruitBank.Common/Interfaces/IFruitBankDataControllerCommon.cs b/FruitBank.Common/Interfaces/IFruitBankDataControllerCommon.cs index 703e61fe..7815ec09 100644 --- a/FruitBank.Common/Interfaces/IFruitBankDataControllerCommon.cs +++ b/FruitBank.Common/Interfaces/IFruitBankDataControllerCommon.cs @@ -36,7 +36,7 @@ public interface IFruitBankDataControllerCommon public Task UpdateEkaerHistory(EkaerHistory ekaerHistory); public Task GenerateEkaerXmlDocument(int foreignKey, bool isOutgoing); public Task CreateEkaerHistory(int foreignKey, bool isOutgoing); - public Task CreateMissingEkaerHistories(DateTime fromDate); + public Task CreateMissingEkaerHistories(DateTime fromDate); public Task GetEkaerHistoryCount(EkaerHistoryFilter filter); #endregion EkaerHistory diff --git a/FruitBank.Common/Services/Ekaer/EkaerConsignment.cs b/FruitBank.Common/Services/Ekaer/EkaerConsignment.cs new file mode 100644 index 00000000..cb20cfbf --- /dev/null +++ b/FruitBank.Common/Services/Ekaer/EkaerConsignment.cs @@ -0,0 +1,70 @@ +using AyCode.Services.Nav.Ekaer.Models; +using TradeReasonType = AyCode.Services.Nav.Ekaer.Models.Common.TradeReasonType; + +namespace FruitBank.Common.Services.Ekaer; + +/// +/// Irány-független, normalizált szállítmány-modell. A bejövő (ShippingDocument-csoport) és a kimenő +/// (OrderDto) forrás EBBE képződik (a adapterei), és innen épül MIND a +/// NAV tradeCard (), MIND a bejelentés-kötelezettség +/// (). Í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. +/// +/// +/// A fel-/lerakodási hely és a jármű már a NAV-típus (, ), +/// mert a saját telephely (company.Site) 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. +/// +public sealed class EkaerConsignment +{ + /// 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. + public int ForeignKey { get; init; } + + public bool IsOutgoing { get; init; } + + /// Számla-pénznem (ISO 4217). A tétel már KISZÁMOLT HUF-ban. + public string? Currency { get; init; } + + /// Feladó / eladó (a kötelezettség az országkódját nézi). + public EkaerEndpoint Seller { get; init; } = new(); + + /// Címzett / vevő (a kötelezettség az országkódját nézi). + public EkaerEndpoint Buyer { get; init; } = new(); + + /// Felrakodási hely (NAV LocationType — pl. a saját telephely átengedve). + public LocationType? LoadLocation { get; init; } + + /// Lerakodási hely (NAV LocationType — pl. a saját telephely átengedve). + public LocationType? UnloadLocation { get; init; } + + public IReadOnlyList Lines { get; init; } = []; + + public BasicVehicleDetailType? Vehicle { get; init; } + public BasicVehicleDetailType? Trailer { get; init; } + public string? CarrierName { get; init; } +} + +/// 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 +/// / hordozza.) +public sealed class EkaerEndpoint +{ + public string? Name { get; init; } + public string? VatNumber { get; init; } + public string? CountryCode { get; init; } + public string? FullAddress { get; init; } +} + +/// Egy normalizált tétel. A és a már KISZÁMOLT (HUF-ban), +/// hogy a küszöb-summa és a tradeCard ugyanazt használja (egyetlen érték-forrás). +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; } +} diff --git a/FruitBank.Common/Services/Ekaer/EkaerReportability.cs b/FruitBank.Common/Services/Ekaer/EkaerReportability.cs new file mode 100644 index 00000000..16aa7751 --- /dev/null +++ b/FruitBank.Common/Services/Ekaer/EkaerReportability.cs @@ -0,0 +1,83 @@ +using AyCode.Services.Nav.Ekaer; + +namespace FruitBank.Common.Services.Ekaer; + +/// Egy szállítmány EKÁER bejelentés-kötelezettsége. +public enum EkaerObligation +{ + /// Kötelező bejelenteni (sort kell létrehozni). + Required, + /// Nem kötelező (belföld, küszöb alatt) — NEM hiba, nincs üzenet. + NotRequired, + /// A döntés nem hozható meg adathiány/formátumhiba miatt (pl. érvénytelen országkód) — NINCS sor, az okok az üzenetekben. + DataError, +} + +/// 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. +public sealed class EkaerObligationResult +{ + public EkaerObligation Obligation { get; private init; } + public IReadOnlyList 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 errors) => + new() { Obligation = EkaerObligation.DataError, Errors = errors }; +} + +/// +/// EKÁER bejelentés-kötelezettség eldöntése egy normalizált -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 → . +/// +/// +/// 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 -t (bejövőnél a (Shipping, Partner) csoport tételei). +/// +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(); + 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; + } + + /// Érvényes EKÁER országkód: pontosan 2 ASCII betű (ISO-2). Üres / más hosszúságú → érvénytelen. + 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!; +} diff --git a/FruitBank.Common/Services/Ekaer/IShippingToEkaerMapper.cs b/FruitBank.Common/Services/Ekaer/IShippingToEkaerMapper.cs index 85b6aacb..ca2335b9 100644 --- a/FruitBank.Common/Services/Ekaer/IShippingToEkaerMapper.cs +++ b/FruitBank.Common/Services/Ekaer/IShippingToEkaerMapper.cs @@ -37,4 +37,17 @@ public interface IShippingToEkaerMapper /// a kimenő fuvar-adat (OrderDto) — amíg az nincs bekötve, üresen marad (a validátor jelzi). /// TradeCardType MapOrder(OrderDto order, EkaerCompanyInfo company); + + /// + /// Bejövő forrás → normalizált . Egy vagy több -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. + /// + EkaerConsignment ToConsignment(IReadOnlyCollection documents, EkaerCompanyInfo company); + + /// Kimenő forrás → normalizált (egy rendelés = egy szállítmány). + EkaerConsignment ToConsignment(OrderDto order, EkaerCompanyInfo company); + + /// Normalizált szállítmány → NAV tradeCard. KÖZÖS a két irányra; a MapDocument/MapOrder ezt hívja. + TradeCardType BuildTradeCard(EkaerConsignment consignment); } diff --git a/FruitBank.Common/Services/Ekaer/ShippingToEkaerMapper.cs b/FruitBank.Common/Services/Ekaer/ShippingToEkaerMapper.cs index 6fbaa96c..c8044da9 100644 --- a/FruitBank.Common/Services/Ekaer/ShippingToEkaerMapper.cs +++ b/FruitBank.Common/Services/Ekaer/ShippingToEkaerMapper.cs @@ -12,17 +12,19 @@ namespace FruitBank.Common.Services.Ekaer; /// /// -/// Tiszta (állapotmentes) leképező. A feladót és a saját céget egységesen -ként kezeli. -/// A TradeType/TradeReasonType enumokat aliasszal hozzuk be a Models.TradeCardType osztály -/// és a Models.Common.TradeCardType enum névütközése miatt. +/// Tiszta (állapotmentes) leképező. A bejövő (ShippingDocument-csoport) és a kimenő (OrderDto) forrás +/// előbb egy irány-független -re képződik ( +/// / ), majd EBBŐL épül a NAV tradeCard (). +/// Í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 , mezővesztés nélkül átengedve); a feladó/címzett normalizálása a +/// build-ben történik. A TradeType/TradeReasonType enumokat aliasszal hozzuk be a névütközés miatt. /// public sealed class ShippingToEkaerMapper : IShippingToEkaerMapper { - /// A NAV EKÁER magyar rendszer — a „belföld" mindig HU; minden más feladó-ország import. + /// A NAV EKÁER magyar rendszer — a „belföld" alapértéke HU (a vevő-ország feloldásáig). private const string HomeCountry = "HU"; - /// Kimenő pénznem — jelenleg minden HUF (a vevő/rendelés devizája az OrderDto-ban még nincs leképezve; - /// bekötéskor innen jön). HUF → nincs átváltás (rate 1). + /// Kimenő pénznem — jelenleg minden HUF (a vevő/rendelés devizája az OrderDto-ban még nincs leképezve). private const string OutboundCurrency = "HUF"; private readonly IEkaerSettings _settings; @@ -30,6 +32,8 @@ public sealed class ShippingToEkaerMapper : IShippingToEkaerMapper public ShippingToEkaerMapper(IEkaerSettings 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 MapShipping(Shipping shipping, EkaerCompanyInfo company, OperationType operation = OperationType.Create) { ArgumentNullException.ThrowIfNull(shipping); @@ -41,13 +45,13 @@ public sealed class ShippingToEkaerMapper : IShippingToEkaerMapper // Granularitás: egy ShippingDocument → egy tradeCard (lásd EKAER_TODO #5). foreach (var document in shipping.ShippingDocuments ?? []) { + document.Shipping ??= shipping; // a vontató/fuvarozó a Shippingről jön index++; - var rateToHuf = EkaerValueCalculator.ResolveRateToHuf(document.Partner?.Currency, _settings.EurHufRate); operations.Add(new TradeCardOperationType { Index = index, 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(company); - - var rateToHuf = EkaerValueCalculator.ResolveRateToHuf(document.Partner?.Currency, _settings.EurHufRate); - return BuildTradeCard(document.Shipping, document, company, rateToHuf); + return BuildTradeCard(ToConsignment([document], 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) ──────────── + + /// Bejövő: egy vagy több ShippingDocument (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. + public EkaerConsignment ToConsignment(IReadOnlyCollection 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, + }; + } + + /// Kimenő: egy rendelés → egy szállítmány. Nincs Shipping; a kötelezettséget a rendelésre nézzük. + public EkaerConsignment ToConsignment(OrderDto order, EkaerCompanyInfo company) { ArgumentNullException.ThrowIfNull(order); ArgumentNullException.ThrowIfNull(company); 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 + { + ExternalId = item.Id.ToString(), + TradeReason = TradeReasonType.S, // kimenő áru = értékesítés + Vtsz = NormalizeVtsz(item.ProductDto?.Gtin), + Name = item.ProductDto?.Name, + WeightKg = item.GrossWeight, + ValueHuf = EkaerValueCalculator.ItemValueHuf(item, rateToHuf), + }) + .ToList(); + + return new EkaerConsignment { - // Kimenő belföldi értékesítés. (Export `E` + külföldi deviza: későbbi — jelenleg minden HUF/belföld.) - TradeType = TradeType.D, - ModByCarrierEnabled = false, - - // Eladó = mi (kimenő relációban) - SellerName = company.Name, - SellerVatNumber = NormalizeVatNumber(company.TaxId), - SellerCountry = NormalizeCountryCode(company.CountryCode, 2), - SellerAddress = Truncate(company.FullAddress, 200), - - // Címzett = a vevő. Ország jelenleg HU (belföld); a Customer.CountryId-feloldás + export: későbbi. - DestinationName = customer?.Company, - DestinationVatNumber = NormalizeVatNumber(customer?.VatNumber), - DestinationCountry = HomeCountry, - DestinationAddress = Truncate(ComposeCustomerAddress(customer), 200), - - // Felrakodás = a mi telephelyünk; lerakodás = a vevő. + ForeignKey = order.Id, + IsOutgoing = true, + Currency = OutboundCurrency, + Seller = CompanyEndpoint(company), + Buyer = CustomerEndpoint(customer), LoadLocation = company.Site, UnloadLocation = BuildCustomerLocation(customer), - - // Vonó jármű / fuvarozó: a kimenő fuvar-adat (OrderDto) bekötéséig nincs forrás — üresen marad, - // a validátor jelzi (mint a bejövőnél kezdetben). TODO: order. → Vehicle / CarrierText. + Lines = lines, + // Kimenőnél a VEVŐ veszi át / viszi el az árut → ő a fuvarozó. + 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 { - TradeType = ResolveTradeType(seller), + TradeType = ResolveTradeType(consignment), ModByCarrierEnabled = false, // mi jelentünk; a fuvarozó alapból nem módosíthat - // Feladó / eladó = a beszállító - SellerName = seller?.Name, - SellerVatNumber = NormalizeVatNumber(seller?.TaxId), - SellerCountry = NormalizeCountryCode(seller?.CountryCode, 2), - SellerAddress = Truncate(seller?.FullAddress, 200), + SellerName = consignment.Seller.Name, + SellerVatNumber = NormalizeVatNumber(consignment.Seller.VatNumber), + SellerCountry = NormalizeCountryCode(consignment.Seller.CountryCode, 2), + SellerAddress = Truncate(consignment.Seller.FullAddress, 200), - // Címzett = a bejelentő saját cége (bejövő relációban) - DestinationName = company.Name, - DestinationVatNumber = NormalizeVatNumber(company.TaxId), - DestinationCountry = NormalizeCountryCode(company.CountryCode, 2), - DestinationAddress = Truncate(company.FullAddress, 200), + DestinationName = consignment.Buyer.Name, + DestinationVatNumber = NormalizeVatNumber(consignment.Buyer.VatNumber), + DestinationCountry = NormalizeCountryCode(consignment.Buyer.CountryCode, 2), + DestinationAddress = Truncate(consignment.Buyer.FullAddress, 200), - // Fuvarozó (Shipping.CargoPartner). Regisztrált EKAER-azonosító nincs, csak szöveges név. - CarrierText = shipping?.CargoPartner?.Name, + CarrierText = consignment.CarrierName, - // Lerakodás = saját telephely (a cégadatból); felrakodás = a beszállító telephelye. - UnloadLocation = company.Site, - LoadLocation = BuildLoadLocation(seller), + // A helyek kész NAV LocationType-ok (a saját telephely érintetlenül) — átengedve. + LoadLocation = consignment.LoadLocation, + UnloadLocation = consignment.UnloadLocation, }; - // Vonó jármű + vontatmány: az EKÁER külön bejegyzésként kéri (vehicle / vehicle2). - if (shipping?.CargoTruck != null) tradeCard.Vehicle = BuildVehicle(shipping.CargoTruck); - if (shipping?.CargoTrailer != null) tradeCard.Vehicle2 = BuildVehicle(shipping.CargoTrailer); + if (consignment.Vehicle != null) tradeCard.Vehicle = consignment.Vehicle; + if (consignment.Trailer != null) tradeCard.Vehicle2 = consignment.Trailer; - 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; } - /// - /// Belföldi feladó (HU) → D (belföld-belföld), egyébként → I (import). A NAV EKÁER magyar, - /// így a belföld mindig HU; az export (E) jelenleg nincs leképezve (lásd EKAER_TODO #1, #7). - /// - 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() + /// Belföld (a feladó és a címzett országkódja megegyezik) → D; eltérő országok → kimenőnél + /// export (E), bejövőnél import (I). + private static TradeType ResolveTradeType(EkaerConsignment c) { - ItemExternalId = item.Id.ToString(), - // 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. - TradeReason = TradeReasonType.A, - 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) - Value = EkaerValueCalculator.ItemValueHuf(item, rateToHuf), // beszerzési érték HUF-ban (Partner.Currency → árfolyam); 0/ismeretlen → null + if (string.Equals(c.Seller.CountryCode?.Trim(), c.Buyer.CountryCode?.Trim(), StringComparison.OrdinalIgnoreCase)) + return TradeType.D; + return c.IsOutgoing ? TradeType.E : TradeType.I; + } + + private static TradeCardItemType BuildItem(EkaerLine line) => new() + { + 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, }; - /// Kimenő (Order) tétel → tradeCardItem. Értékesítés (S); bruttó tömeg a palettákból; nettó érték HUF-ban. - private static TradeCardItemType BuildOutboundItem(OrderItemDto item, double rateToHuf) => new() + // ── Forrás → normalizálatlan végpont (feladó/címzett: név/adószám/ország/egysoros cím) ───────── + + private static EkaerEndpoint PartnerEndpoint(ICompanyInfoBase? partner) => new() { - ItemExternalId = item.Id.ToString(), - // Kimenő áru = értékesítés → S. (Enum: S=értékesítés, A=beszerzés, W=bérmunka, O=egyéb.) - TradeReason = TradeReasonType.S, - ProductVtsz = NormalizeVtsz(item.ProductDto?.Gtin), // VTSZ — átmenetileg a Gtin oszlopban (FBANKAPP-DMODEL-I-P6X4) - 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 + Name = partner?.Name, + VatNumber = partner?.TaxId, + CountryCode = partner?.CountryCode, + FullAddress = partner?.FullAddress, }; - private static BasicVehicleDetailType BuildVehicle(CargoTruck truck) => new() + private static EkaerEndpoint CompanyEndpoint(EkaerCompanyInfo company) => new() { - PlateNumber = NormalizePlateNumber(truck.LicencePlate), - Country = NormalizeCountryCode(truck.CountryCode, 3), + Name = company.Name, + VatNumber = company.TaxId, + CountryCode = company.CountryCode, + FullAddress = company.FullAddress, }; - /// - /// Felrakodási hely a beszállító adataiból. (Magyar feladónál a NAV a Phone/Email-t is kéri, ami az - /// entitásban nincs — lásd EKAER_TODO #6.) - /// - private static LocationType? BuildLoadLocation(ICompanyInfoBase? seller) + /// A vevő mint fél. Ország jelenleg HU — a Customer.CountryId→ISO feloldás (export E) külön feladat. + private static EkaerEndpoint CustomerEndpoint(Customer? customer) => new() + { + Name = customer?.Company, + VatNumber = customer?.VatNumber, + CountryCode = HomeCountry, + FullAddress = ComposeCustomerAddress(customer), + }; + + // ── Forrás → NAV LocationType / jármű (a saját telephelyet az adapter közvetlenül adja) ──────── + + /// 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. + private static LocationType? BuildLocation(ICompanyInfoBase? seller) { if (seller is null) return null; return new LocationType @@ -195,16 +253,7 @@ public sealed class ShippingToEkaerMapper : IShippingToEkaerMapper }; } - /// A vevő egysoros címe (irsz + város + utca) a NAV destinationAddress-hez. - 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()); - } - - /// Lerakodási hely a vevő adataiból (kimenő reláció). Ország jelenleg HU (belföld). + /// Lerakodási hely a vevő adataiból (kimenő). Ország jelenleg HU (belföld). private static LocationType? BuildCustomerLocation(Customer? customer) { 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), + }; + } + + /// 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. + private double SafeRateToHuf(string? currency) + => EkaerValueCalculator.IsHuf(currency) ? 1d : (_settings.EurHufRate > 0 ? _settings.EurHufRate : 0d); + + /// A vevő egysoros címe (irsz + város + utca) a NAV destinationAddress-hez. + 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) ───────────────────────────────────────────────────────────── + /// Adószám normalizálása. Pattern: [0-9A-Z-]{1,15}. private static string? NormalizeVatNumber(string? value) { diff --git a/FruitBankHybrid.Shared.Tests/Ekaer/EkaerReportabilityTests.cs b/FruitBankHybrid.Shared.Tests/Ekaer/EkaerReportabilityTests.cs new file mode 100644 index 00000000..b12d01c8 --- /dev/null +++ b/FruitBankHybrid.Shared.Tests/Ekaer/EkaerReportabilityTests.cs @@ -0,0 +1,140 @@ +using FruitBank.Common.Services.Ekaer; +using TradeReasonType = AyCode.Services.Nav.Ekaer.Models.Common.TradeReasonType; + +namespace FruitBankHybrid.Shared.Tests.Ekaer; + +/// +/// Unit tesztek a bejelentés-kötelezettség-eldöntésére. Tisztán memóriában felépített +/// -eken fut (nincs hálózat/DB), determinisztikus. Küszöb: 200 kg / 250 000 Ft. +/// +[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"); + } +} diff --git a/FruitBankHybrid.Shared.Tests/Ekaer/ShippingToEkaerMapperTests.cs b/FruitBankHybrid.Shared.Tests/Ekaer/ShippingToEkaerMapperTests.cs index be7ac26c..cf6f52c0 100644 --- a/FruitBankHybrid.Shared.Tests/Ekaer/ShippingToEkaerMapperTests.cs +++ b/FruitBankHybrid.Shared.Tests/Ekaer/ShippingToEkaerMapperTests.cs @@ -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 ---------------------------------------------- [TestMethod] @@ -216,4 +238,55 @@ public sealed class ShippingToEkaerMapperTests [TestMethod] public void MapShipping_NullCompany_Throws() => Assert.ThrowsExactly(() => 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(() => 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); + } } diff --git a/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerHistory.razor b/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerHistory.razor index 6fb0fa17..70a2391b 100644 --- a/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerHistory.razor +++ b/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerHistory.razor @@ -5,14 +5,17 @@ @using AyCode.Services.Nav @using AyCode.Services.Nav.Ekaer.Models @using AyCode.Utils.Extensions +@using FruitBank.Common.Dtos @using FruitBank.Common.Entities @using FruitBankHybrid.Shared.Databases +@using FruitBankHybrid.Shared.Extensions @using FruitBankHybrid.Shared.Services.Loggers @using FruitBankHybrid.Shared.Services.SignalRs @inject IEnumerable LogWriters @inject FruitBankSignalRClient FruitBankSignalRClient @inject IJSRuntime JS +@inject IDialogService DialogService @@ -147,6 +150,10 @@ /// hogy a fül-számlálók azonnal frissüljenek, mert a sor átkerülhet másik tabra. [Parameter] public EventCallback OnDataChanged { get; set; } + /// 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. + [Parameter] public EventCallback OnBusyChanged { get; set; } + public bool IsMasterGrid => ParentDataItem == null; private LoggerClient _logger; @@ -238,13 +245,23 @@ { if (_creatingMissing) return; _creatingMissing = true; + await OnBusyChanged.InvokeAsync(true); // Loading panel BE — a művelet lassú lehet (kimenő ág) + EkaerCreateResult? result = null; try { - var createdCount = await FruitBankSignalRClient.CreateMissingEkaerHistories(CreateFromDate); - _logger.Info($"CreateMissingEkaerHistories; created: {createdCount}; fromDate: {CreateFromDate:yyyy.MM.dd}"); + result = await FruitBankSignalRClient.CreateMissingEkaerHistories(CreateFromDate); + 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) { @@ -253,7 +270,12 @@ finally { _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) diff --git a/FruitBankHybrid.Shared/Pages/Ekaer.razor b/FruitBankHybrid.Shared/Pages/Ekaer.razor index 8dda41ef..3ccbae1a 100644 --- a/FruitBankHybrid.Shared/Pages/Ekaer.razor +++ b/FruitBankHybrid.Shared/Pages/Ekaer.razor @@ -17,13 +17,13 @@ - + - + - + diff --git a/FruitBankHybrid.Shared/Pages/Ekaer.razor.cs b/FruitBankHybrid.Shared/Pages/Ekaer.razor.cs index ce186905..8c7a66bb 100644 --- a/FruitBankHybrid.Shared/Pages/Ekaer.razor.cs +++ b/FruitBankHybrid.Shared/Pages/Ekaer.razor.cs @@ -2,6 +2,7 @@ using AyCode.Core.Loggers; using FruitBank.Common.Entities; using FruitBank.Common.Models; using FruitBankHybrid.Shared.Components.Grids.Ekaers; +using FruitBankHybrid.Shared.Databases; using FruitBankHybrid.Shared.Services.Loggers; using FruitBankHybrid.Shared.Services.SignalRs; using Mango.Nop.Core.Loggers; @@ -45,6 +46,13 @@ public partial class Ekaer : ComponentBase await RefreshNeedsCompletionCountAsync(); } + /// A grid jelzi a hosszú művelet (sorok létrehozása) elejét (true) / végét (false) → az oldal Loading panelje meg/elrejtve. + private Task OnGridBusyChanged(bool busy) + { + LoadingPanelVisibility.Visible = busy; + return InvokeAsync(StateHasChanged); + } + /// 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). private async Task RefreshNeedsCompletionCountAsync() { diff --git a/FruitBankHybrid.Shared/Services/SignalRs/FruitBankSignalRClient.cs b/FruitBankHybrid.Shared/Services/SignalRs/FruitBankSignalRClient.cs index b0a74468..f4a15b07 100644 --- a/FruitBankHybrid.Shared/Services/SignalRs/FruitBankSignalRClient.cs +++ b/FruitBankHybrid.Shared/Services/SignalRs/FruitBankSignalRClient.cs @@ -81,7 +81,7 @@ namespace FruitBankHybrid.Shared.Services.SignalRs public Task UpdateEkaerHistory(EkaerHistory ekaerHistory) => PostDataAsync(SignalRTags.UpdateEkaerHistory, ekaerHistory); public Task GenerateEkaerXmlDocument(int foreignKey, bool isOutgoing) => GetByIdAsync(SignalRTags.GenerateEkaerXmlDocument, [foreignKey, isOutgoing]); public Task CreateEkaerHistory(int foreignKey, bool isOutgoing) => GetByIdAsync(SignalRTags.CreateEkaerHistory, [foreignKey, isOutgoing]); - public Task CreateMissingEkaerHistories(DateTime fromDate) => GetByIdAsync(SignalRTags.CreateMissingEkaerHistories, fromDate); + public Task CreateMissingEkaerHistories(DateTime fromDate) => GetByIdAsync(SignalRTags.CreateMissingEkaerHistories, fromDate); public Task GetEkaerHistoryCount(EkaerHistoryFilter filter) => GetByIdAsync(SignalRTags.GetEkaerHistoryCount, filter); #endregion EkaerHistory diff --git a/FruitBankHybrid.Shared/appsettings.json b/FruitBankHybrid.Shared/appsettings.json index 82af839e..a66b1232 100644 --- a/FruitBankHybrid.Shared/appsettings.json +++ b/FruitBankHybrid.Shared/appsettings.json @@ -36,9 +36,9 @@ "UseStatefulReconnect": true }, "AcBinaryHubProtocol": { - "ProtocolMode": "AsyncSegment", + "ProtocolMode": "Bytes", "BufferSize": 4096, "FlushPolicy": "Coalesced", - "FlushTimeout": "00:00:10" + "FlushTimeout": "00:00:15" } } diff --git a/FruitBankHybrid.Web.Client/wwwroot/appsettings.json b/FruitBankHybrid.Web.Client/wwwroot/appsettings.json index 82af839e..a66b1232 100644 --- a/FruitBankHybrid.Web.Client/wwwroot/appsettings.json +++ b/FruitBankHybrid.Web.Client/wwwroot/appsettings.json @@ -36,9 +36,9 @@ "UseStatefulReconnect": true }, "AcBinaryHubProtocol": { - "ProtocolMode": "AsyncSegment", + "ProtocolMode": "Bytes", "BufferSize": 4096, "FlushPolicy": "Coalesced", - "FlushTimeout": "00:00:10" + "FlushTimeout": "00:00:15" } } diff --git a/FruitBankHybrid.Web/appsettings.json b/FruitBankHybrid.Web/appsettings.json index 82af839e..a66b1232 100644 --- a/FruitBankHybrid.Web/appsettings.json +++ b/FruitBankHybrid.Web/appsettings.json @@ -36,9 +36,9 @@ "UseStatefulReconnect": true }, "AcBinaryHubProtocol": { - "ProtocolMode": "AsyncSegment", + "ProtocolMode": "Bytes", "BufferSize": 4096, "FlushPolicy": "Coalesced", - "FlushTimeout": "00:00:10" + "FlushTimeout": "00:00:15" } }