using AyCode.Core.Interfaces; using AyCode.Entities; using AyCode.Services.Nav.Ekaer; using AyCode.Services.Nav.Ekaer.Models; using FruitBank.Common.Dtos; using FruitBank.Common.Entities; using Nop.Core.Domain.Customers; using TradeReasonType = AyCode.Services.Nav.Ekaer.Models.Common.TradeReasonType; using TradeType = AyCode.Services.Nav.Ekaer.Models.Common.TradeType; namespace FruitBank.Common.Services.Ekaer; /// /// /// 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" 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). private const string OutboundCurrency = "HUF"; private readonly IEkaerSettings _settings; 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); ArgumentNullException.ThrowIfNull(company); var operations = new List(); var index = 0; // 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++; operations.Add(new TradeCardOperationType { Index = index, Operation = operation, TradeCard = BuildTradeCard(ToConsignment([document], company)), }); } return operations; } public TradeCardType MapDocument(ShippingDocument document, EkaerCompanyInfo company) { ArgumentNullException.ThrowIfNull(document); ArgumentNullException.ThrowIfNull(company); 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, PartnerDepot)-ra 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, PartnerDepot)-ra csoportosít) var shipping = first.Shipping; // azonos Shipping; a kapunál lehet null (a jármű ott nem kell) var rateToHuf = SafeRateToHuf(partner?.Currency); // A depot TaxId-ja Partner-passthrough (a nested Partner-t nem töltjük külön query-vel) — ugyanaz a minta, // mint a MapShipping `document.Shipping ??=` visszakötése. if (first.PartnerDepot is { } depot) depot.Partner ??= partner; var lines = documents .SelectMany(d => d.ShippingItems ?? []) .Select(item => new EkaerLine { ExternalId = item.Id.ToString(), TradeReason = TradeReasonType.S, // S = értékesítés ÉS beszerzés (egy kód); az A kivezetett, D-nél csak S adható Vtsz = NormalizeVtsz(item.ProductDto?.Gtin), Name = item.ProductName, // A szállítólevélen szereplő bruttó — a bejelentés a kísérő dokumentum adatairól megy (mint a value), // nem a (későbbi) mérlegelésről; méretlen tétel így nem 0 kg-mal jelenik meg. WeightKg = item.GrossWeightOnDocument, ValueHuf = EkaerValueCalculator.ItemValueHuf(item, rateToHuf), }) .ToList(); return new EkaerConsignment { ForeignKey = first.Id, IsOutgoing = false, Currency = partner?.Currency, Seller = PartnerEndpoint(partner), Buyer = CompanyEndpoint(company), // A felrakodási hely a DEPOT címe (T-D8R4) — a generate-út betölti (LoadWith) és guardolja (TryDepotError). // A kapu-útvonalon a depot nincs betöltve → partner-központi cím, ami ott rendben van: a kötelezettség-döntés címet nem használ. LoadLocation = BuildLocation((ICompanyInfoBase?)first.PartnerDepot ?? partner), 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 = SafeRateToHuf(OutboundCurrency); // jelenleg minden HUF 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 { ForeignKey = order.Id, IsOutgoing = true, Currency = OutboundCurrency, Seller = CompanyEndpoint(company), Buyer = CustomerEndpoint(customer), LoadLocation = company.Site, // A lerakodási hely az orderre választott telephely (snapshot); ha nincs beállítva, // fallback a vevő fő címére (a korábbi viselkedés). UnloadLocation = BuildSiteLocation(order.Site) ?? BuildCustomerLocation(customer), Lines = lines, // Kimenőnél nincs külön szállítmányozó: a vevő maga viszi el → carrierText üres (a NAV nem követeli). CarrierName = null, // A vonó jármű rendszáma az orderre mentett LicensePlate-ből (a customer adja meg, ill. az auto-hozzárendelés). Vehicle = BuildVehicleFromPlate(order.LicensePlate), }; } // ── 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) { ArgumentNullException.ThrowIfNull(consignment); var tradeCard = new TradeCardType { TradeType = ResolveTradeType(consignment), ModByCarrierEnabled = false, // mi jelentünk; a fuvarozó alapból nem módosíthat SellerName = consignment.Seller.Name, SellerVatNumber = NormalizeVatNumber(consignment.Seller.VatNumber), SellerCountry = NormalizeCountryCode(consignment.Seller.CountryCode, 2), SellerAddress = Truncate(consignment.Seller.FullAddress, 200), DestinationName = consignment.Buyer.Name, DestinationVatNumber = NormalizeVatNumber(consignment.Buyer.VatNumber), DestinationCountry = NormalizeCountryCode(consignment.Buyer.CountryCode, 2), DestinationAddress = Truncate(consignment.Buyer.FullAddress, 200), CarrierText = consignment.CarrierName, // A helyek kész NAV LocationType-ok (a saját telephely érintetlenül) — átengedve. LoadLocation = consignment.LoadLocation, UnloadLocation = consignment.UnloadLocation, }; if (consignment.Vehicle != null) tradeCard.Vehicle = consignment.Vehicle; if (consignment.Trailer != null) tradeCard.Vehicle2 = consignment.Trailer; foreach (var line in consignment.Lines) tradeCard.Items.Add(BuildItem(line)); return tradeCard; } /// 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) { 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, }; // ── 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() { Name = partner?.Name, VatNumber = partner?.TaxId, CountryCode = partner?.CountryCode, FullAddress = partner?.FullAddress, }; private static EkaerEndpoint CompanyEndpoint(EkaerCompanyInfo company) => new() { Name = company.Name, VatNumber = company.TaxId, CountryCode = company.CountryCode, FullAddress = company.FullAddress, }; /// 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 { Name = seller.Name, VatNumber = NormalizeVatNumber(seller.TaxId), Country = NormalizeCountryCode(seller.CountryCode, 2), ZipCode = NormalizeZipCode(seller.PostalCode), City = seller.City, Street = seller.Street, }; } /// 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; return new LocationType { Name = customer.Company, VatNumber = NormalizeVatNumber(customer.VatNumber), Country = HomeCountry, ZipCode = NormalizeZipCode(customer.ZipPostalCode), City = customer.City, Street = customer.StreetAddress, }; } private static BasicVehicleDetailType? BuildVehicle(CargoTruck? truck) { if (truck is null) return null; return new BasicVehicleDetailType { PlateNumber = NormalizePlateNumber(truck.LicencePlate), Country = NormalizeCountryCode(truck.CountryCode, 3), }; } /// Lerakodási hely az orderre választott telephely-snapshotból (kimenő). A snapshot már a rendelés /// pillanatában befagyott LocationType-mezőket hordoz (név, adószám, irsz., város, utca, ország). private static LocationType? BuildSiteLocation(OrderSiteSnapshot? site) { if (site is null) return null; return new LocationType { Name = site.Name, VatNumber = NormalizeVatNumber(site.VatNumber), Country = NormalizeCountryCode(site.CountryCode, 2), ZipCode = NormalizeZipCode(site.ZipCode), City = site.City, Street = site.Street, }; } /// A vonó jármű az orderre mentett rendszámból (kimenő). Ország jelenleg belföld (HU); a rendszámot /// a meglévő NAV-pattern szerint normalizáljuk. Üres/érvénytelen rendszám → nincs jármű (a validátor jelzi). private static BasicVehicleDetailType? BuildVehicleFromPlate(string? plateNumber) { var plate = NormalizePlateNumber(plateNumber); if (plate is null) return null; return new BasicVehicleDetailType { PlateNumber = plate, Country = NormalizeCountryCode(HomeCountry, 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) { if (string.IsNullOrWhiteSpace(value)) return null; var cleaned = new string([.. value.ToUpperInvariant().Where(c => char.IsAsciiLetterOrDigit(c) || c == '-')]); return Truncate(EmptyToNull(cleaned), 15); } /// Országkód normalizálása. Pattern: [A-Z]{1,maxLen} (seller/location: 2, jármű: 3). private static string? NormalizeCountryCode(string? value, int maxLen) { if (string.IsNullOrWhiteSpace(value)) return null; var cleaned = new string([.. value.ToUpperInvariant().Where(char.IsAsciiLetter)]); return Truncate(EmptyToNull(cleaned), maxLen); } /// Rendszám normalizálása. Pattern: [A-Z0-9ÖŐÜŰ]{4,15} — kötőjel/szóköz NEM engedett. private static string? NormalizePlateNumber(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; var cleaned = new string([.. value.ToUpperInvariant().Where(c => char.IsAsciiLetterOrDigit(c) || c is 'Ö' or 'Ő' or 'Ü' or 'Ű')]); return Truncate(EmptyToNull(cleaned), 15); } /// Irányítószám normalizálása. Pattern: [A-Z0-9 -]{2,10} vagy üres. private static string? NormalizeZipCode(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; var cleaned = new string([.. value.ToUpperInvariant().Where(c => char.IsAsciiLetterOrDigit(c) || c is ' ' or '-')]); return Truncate(EmptyToNull(cleaned), 10); } /// VTSZ normalizálása. Pattern: [0-9]{4,8} — a forrásban tagolt formátum is lehet /// („0805 10 00", „0805.10.00"), ezért csak a számjegyek maradnak; a 4-nél rövidebbet a validátor jelzi. private static string? NormalizeVtsz(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; var cleaned = new string([.. value.Where(char.IsAsciiDigit)]); return Truncate(EmptyToNull(cleaned), 8); } private static string? EmptyToNull(string? value) => string.IsNullOrEmpty(value) ? null : value; private static string? Truncate(string? value, int maxLen) => value is null ? null : value.Length <= maxLen ? value : value[..maxLen]; }