diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 983a40cc..3d148c8f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -61,7 +61,13 @@ "Skill(update-config)", "Skill(update-config:*)", "PowerShell($script = \"H:\\\\Applications\\\\Mango\\\\Source\\\\FruitBankHybridApp\\\\.claude\\\\hooks\\\\db-dev-guard.ps1\"; \"--- A\\) PROD \\(deny vart\\):\"; '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"sqlcmd -S 1.2.3.4 -Q \\\\\"x\\\\\" # Initial Catalog=FruitBank;User ID=sa\"}}' | powershell -NoProfile -ExecutionPolicy Bypass -File $script; \"--- B\\) DEV \\(semmi vart\\):\"; '{\"tool_name\":\"PowerShell\",\"tool_input\":{\"command\":\"$cs = \\\\\"Data Source=x;Initial Catalog=FruitBank_DEV;\\\\\"\"}}' | powershell -NoProfile -ExecutionPolicy Bypass -File $script; \"--- C\\) nincs conn \\(semmi vart\\):\"; '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"ls -la\"}}' | powershell -NoProfile -ExecutionPolicy Bypass -File $script; \"--- D\\) Database= prod \\(deny vart\\):\"; '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"x Database=MangoManagement;y\"}}' | powershell -NoProfile -ExecutionPolicy Bypass -File $script; \"--- KESZ\")", - "Bash(cd \"H:/Applications/Mango/Source/FruitBankHybridApp\" && grep -rhi \"DevExpress.Blazor\" --include=\"*.csproj\" . 2>/dev/null | grep -i version | head -5; echo \"---packages.lock/obj---\"; find . -path \"*DevExpress.Blazor*\" -name \"*.nuspec\" 2>/dev/null | head; echo \"---globalpackages---\"; ls ~/.nuget/packages/ 2>/dev/null | grep -i devexpress.blazor | head)" + "Bash(cd \"H:/Applications/Mango/Source/FruitBankHybridApp\" && grep -rhi \"DevExpress.Blazor\" --include=\"*.csproj\" . 2>/dev/null | grep -i version | head -5; echo \"---packages.lock/obj---\"; find . -path \"*DevExpress.Blazor*\" -name \"*.nuspec\" 2>/dev/null | head; echo \"---globalpackages---\"; ls ~/.nuget/packages/ 2>/dev/null | grep -i devexpress.blazor | head)", + "PowerShell($cs = \"Data Source=100.73.220.50;Initial Catalog=FruitBank_DEV;Integrated Security=False;User ID=sa;Password=v6f_?xNfg9N1;TrustServerCertificate=True\"; $conn = New-Object System.Data.SqlClient.SqlConnection\\($cs\\); $conn.Open\\(\\); $cmd = $conn.CreateCommand\\(\\); $cmd.CommandText = \"SELECT Id, ForeignKey, StatusId, LEFT\\(ISNULL\\(ErrorText,''\\), 80\\) AS Err, CASE WHEN XmlDoc LIKE '%%' THEN SUBSTRING\\(XmlDoc, CHARINDEX\\('', XmlDoc\\) + 17, 40\\) ELSE '\\(nincs destinationName elem\\)' END AS DestName, LEN\\(XmlDoc\\) AS XmlLen, Modified FROM dbo.fbEkaerHistory WHERE Id IN \\(1,2\\) ORDER BY Id\"; $r = $cmd.ExecuteReader\\(\\); while \\($r.Read\\(\\)\\) { \"Id=$\\($r['Id']\\) FK=$\\($r['ForeignKey']\\) StatusId=$\\($r['StatusId']\\) XmlLen=$\\($r['XmlLen']\\) Modified=$\\($r['Modified']\\)\"; \" DestName: $\\($r['DestName']\\)\"; \" Err: $\\($r['Err']\\)\"; \"---\" }; $r.Close\\(\\); $conn.Close\\(\\))", + "Bash(find /h/Applications/Mango/Source -iname \"appsettings*.json\" -path \"*Nop.Web*\" 2>/dev/null | head -20)", + "Bash(grep -c -a \"ConversionRate\" FruitBank.Common.dll)", + "Bash(grep -c -a \"ConversionRate\" 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)" ] } } diff --git a/FruitBank.Common.Server/Services/Ekaer/FruitBankEkaerService.cs b/FruitBank.Common.Server/Services/Ekaer/FruitBankEkaerService.cs index c5384fb4..9e21f0b8 100644 --- a/FruitBank.Common.Server/Services/Ekaer/FruitBankEkaerService.cs +++ b/FruitBank.Common.Server/Services/Ekaer/FruitBankEkaerService.cs @@ -9,22 +9,22 @@ namespace FruitBank.Common.Server.Services.Ekaer; /// /// /// A teljes lánc: map (, FruitBank.Common) → -/// validate → send (, AyCode.Services). A NAV-fiók hitelesítő adatait -/// (INavCredentials) és a saját cégadatokat () a DI szolgáltatja. +/// validate → send (, AyCode.Services). A saját cégadatot +/// () és a NAV-fiók hitelesítő adatait a DI szolgáltatja. /// public sealed class FruitBankEkaerService : IFruitBankEkaerService { private readonly IShippingToEkaerMapper _mapper; private readonly IEkaerSubmitService _submitService; private readonly IEkaerTradeCardValidator _validator; - private readonly EkaerCompanyInfo _company; + private readonly EkaerSettings _settings; - public FruitBankEkaerService(IShippingToEkaerMapper mapper, IEkaerSubmitService submitService, IEkaerTradeCardValidator validator, EkaerCompanyInfo company) + public FruitBankEkaerService(IShippingToEkaerMapper mapper, IEkaerSubmitService submitService, IEkaerTradeCardValidator validator, EkaerSettings settings) { _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); _submitService = submitService ?? throw new ArgumentNullException(nameof(submitService)); _validator = validator ?? throw new ArgumentNullException(nameof(validator)); - _company = company ?? throw new ArgumentNullException(nameof(company)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); } public Task SubmitShippingAsync(Shipping shipping, OperationType operation = OperationType.Create, CancellationToken cancellationToken = default) @@ -32,7 +32,7 @@ public sealed class FruitBankEkaerService : IFruitBankEkaerService ArgumentNullException.ThrowIfNull(shipping); // map (FruitBank.Common) → submit: validate → send (AyCode.Services) - var operations = _mapper.MapShipping(shipping, _company, operation); + var operations = _mapper.MapShipping(shipping, _settings.Company, operation); return _submitService.SubmitAsync(operations, cancellationToken); } @@ -42,17 +42,29 @@ public sealed class FruitBankEkaerService : IFruitBankEkaerService ekaerHistory ??= new EkaerHistory { ForeignKey = document.Id, IsOutgoing = false }; + // Config-ellenőrzés a leképezés ELŐTT: külföldi (nem HUF) feladónál az árfolyam kötelező — különben a + // value csendben 0 lenne. Hibás configgal nem generálunk félrevezető XML-t, hanem jelezzük a hibát. + if (!EkaerValueCalculator.IsHuf(document.Partner?.Currency) && _settings.EurHufRate <= 0) + { + ekaerHistory.Status = EkaerStatus.ValidationError; + ekaerHistory.ErrorText = "EKÁER EUR-HUF árfolyam nincs konfigurálva (appsettings Ekaer:ExchangeRate:EurHuf) — a tétel-érték nem számolható."; + return ekaerHistory; + } + var operation = new TradeCardOperationType { Index = 1, Operation = OperationType.Create, - TradeCard = _mapper.MapDocument(document, _company), + TradeCard = _mapper.MapDocument(document, _settings.Company), }; var errors = _validator.Validate(operation); // Az XML validációs hibánál IS tárolódik — a detail-nézetben így látszik, mi hiányzik. ekaerHistory.XmlDoc = NavXmlHelper.Serialize(operation.TradeCard); + // Audit: a value-számításhoz TÉNYLEGESEN alkalmazott árfolyam (HUF → 1, külföldi → FX-ráta) — pont az, + // amivel a value készült. A NAV-sémában nincs árfolyam-mező, csak a HUF value; ez az oszlop őrzi meg, hogyan. + ekaerHistory.ConversionRate = EkaerValueCalculator.ResolveRateToHuf(document.Partner?.Currency, _settings.EurHufRate); ekaerHistory.Status = errors.Count == 0 ? EkaerStatus.Generated : EkaerStatus.ValidationError; ekaerHistory.ErrorText = errors.Count == 0 ? null : string.Join(Environment.NewLine, errors.Select(e => e.ErrorMessage)); diff --git a/FruitBank.Common/Entities/EkaerHistory.cs b/FruitBank.Common/Entities/EkaerHistory.cs index 218952d3..204c3a81 100644 --- a/FruitBank.Common/Entities/EkaerHistory.cs +++ b/FruitBank.Common/Entities/EkaerHistory.cs @@ -2,6 +2,7 @@ using AyCode.Core.Serializers.Attributes; using AyCode.Core.Serializers.Toons; using AyCode.Interfaces.TimeStampInfo; +using LinqToDB; using LinqToDB.Mapping; using Mango.Nop.Core.Entities; @@ -37,6 +38,10 @@ public sealed class EkaerHistory: MgEntityBase, ITimeStampInfo [ToonDescription(Purpose = "The generated NAV EKÁER tradeCard request XML exactly as it was (or will be) submitted — audit copy and source of the read-only detail view. Null until the first Generate.")] public string? XmlDoc { get; set; } + [Column(DataType = DataType.DecFloat)] + [ToonDescription(Purpose = "The conversion rate actually applied when computing this declaration's item values to HUF: 1 for domestic (HUF) suppliers (no conversion), the FX rate (e.g. EUR→HUF) for foreign suppliers. Null before the first Generate. Currency-agnostic by design — works for any source currency. Audit trail: the invoice amount times this rate yields the HUF value in the tradeCard; the NAV schema has no rate field, so this column preserves how the value was derived.")] + public double? ConversionRate { get; set; } + [ToonDescription(Purpose = "The EKÁER number (TCN) assigned by NAV after a successful submission. Null until the declaration is accepted.")] public string? EkaerNumber { get; set; } diff --git a/FruitBank.Common/Entities/Partner.cs b/FruitBank.Common/Entities/Partner.cs index 45d4205e..d8e492ce 100644 --- a/FruitBank.Common/Entities/Partner.cs +++ b/FruitBank.Common/Entities/Partner.cs @@ -11,6 +11,9 @@ namespace FruitBank.Common.Entities; [System.ComponentModel.DataAnnotations.Schema.Table(FruitBankConstClient.PartnerDbTableName)] public sealed class Partner : PartnerBase, IPartner { + [ToonDescription(Purpose = "Whether shipments from this supplier are subject to NAV EKÁER reporting. Default true; set false for partners that do not require EKÁER (e.g. multiple companies sharing a wholesale-market address where no road transport between them occurs).")] + public bool IsEkaer { get; set; } = true; + [Association(ThisKey = nameof(Id), OtherKey = nameof(PartnerDepot.PartnerId), CanBeNull = true)] public List? PartnerDepots { get; set; } diff --git a/FruitBank.Common/Interfaces/IPartner.cs b/FruitBank.Common/Interfaces/IPartner.cs index 953fc33a..5dec94af 100644 --- a/FruitBank.Common/Interfaces/IPartner.cs +++ b/FruitBank.Common/Interfaces/IPartner.cs @@ -13,6 +13,7 @@ public interface ICargoPartner : IPartnerBase public interface IPartner : IPartnerBase { + bool IsEkaer { get; set; } List? PartnerDepots { get; set; } List? ShippingDocuments { get; set; } } diff --git a/FruitBank.Common/Services/Ekaer/EkaerSettings.cs b/FruitBank.Common/Services/Ekaer/EkaerSettings.cs new file mode 100644 index 00000000..94d7b5e4 --- /dev/null +++ b/FruitBank.Common/Services/Ekaer/EkaerSettings.cs @@ -0,0 +1,28 @@ +using AyCode.Services.Nav.Ekaer; + +namespace FruitBank.Common.Services.Ekaer; + +/// +/// A teljes EKÁER konfiguráció egy helyen (configból, appsettings.json „Ekaer" szekció): a bejelentő +/// cégadata + a küszöbök + az árfolyam. A küszöbök és az árfolyam évente / jogszabály szerint változhatnak, +/// ezért configban élnek — nem a kódban beégetve. +/// +public sealed class EkaerSettings +{ + /// A bejelentő saját cégadatai (címzett a bejövő relációban) + a lerakodási hely. + public EkaerCompanyInfo Company { get; set; } = new(); + + /// EUR→HUF átváltási árfolyam a tétel-érték HUF-ra számításához. Forrás: MNB napi középárfolyam + /// (a NAV nem közöl külön EKÁER-árfolyamot). SZÁNDÉKOSAN nincs default: ha a config nem töltődik be, 0 marad, + /// és a külföldi érték-átváltás hibát dob () — így elavult / + /// beégetett árfolyammal soha nem számolunk. + public double EurHufRate { get; set; } + + /// Tömeg-küszöb kg-ban: e felett (vagy az érték-küszöb felett) kell EKÁER. Kockázatos élelmiszer: 200 kg. + /// Default nélkül: be nem töltött config → 0 → minden szállítmány „átlépi" (mindent jelentünk, a biztonság felé). + public double ThresholdWeightKg { get; set; } + + /// Érték-küszöb HUF-ban (nettó): e felett (vagy a tömeg-küszöb felett) kell EKÁER. Kockázatos élelmiszer: 250 000 Ft. + /// Default nélkül: be nem töltött config → 0 → minden szállítmány „átlépi" (mindent jelentünk, a biztonság felé). + public long ThresholdValueHuf { get; set; } +} diff --git a/FruitBank.Common/Services/Ekaer/EkaerValueCalculator.cs b/FruitBank.Common/Services/Ekaer/EkaerValueCalculator.cs new file mode 100644 index 00000000..d028f688 --- /dev/null +++ b/FruitBank.Common/Services/Ekaer/EkaerValueCalculator.cs @@ -0,0 +1,40 @@ +using FruitBank.Common.Entities; + +namespace FruitBank.Common.Services.Ekaer; + +/// +/// A tétel-érték HUF-ra számítása — közös a leképezés (tradeCardItem.value) és a küszöb-kapu +/// (CreateMissingEkaerHistories) között, hogy a kétféle számítás ne csúszhasson el egymástól. +/// +/// +/// A forrás-pénznem a Partner.Currency (ISO 4217). Jelenleg csak HUF és EUR van használatban +/// (100%-ban a partnerből következik): HUF → nincs átváltás, minden más → EUR-ként, az árfolyammal. +/// Ha valaha más deviza is megjelenik, ezt a feltevést bővíteni kell (akkor per-deviza árfolyam kell). +/// +public static class EkaerValueCalculator +{ + public static bool IsHuf(string? currency) => string.Equals(currency?.Trim(), "HUF", StringComparison.OrdinalIgnoreCase); + + /// A forrás-pénznem → HUF szorzó. HUF → 1 (árfolyam nem kell). Minden más (EUR) → a megadott + /// EUR-HUF árfolyam; ha az hiányzik/0, — soha nem számolunk elavult / + /// üres árfolyammal (a config-default szándékosan nincs). + public static double ResolveRateToHuf(string? currency, double eurHufRate) + { + if (IsHuf(currency)) return 1d; + if (eurHufRate <= 0) + throw new InvalidOperationException( + "EKÁER EUR-HUF árfolyam nincs konfigurálva (appsettings Ekaer:ExchangeRate:EurHuf) — külföldi (nem HUF) értéket nem lehet HUF-ra számolni."); + return eurHufRate; + } + + /// Egy tétel értéke a számla pénznemében (egységár × dokumentum-mennyiség), átváltás előtt. + public static double ItemLineValue(ShippingItem item) => item.UnitPriceOnDocument * item.QuantityOnDocument; + + /// Egy tétel értéke HUF-ban, egészre kerekítve. A NAV value > 0-t vár — 0/ismeretlen + /// esetén null (a mezőt nem töltjük, a validátor jelzi, ha kötelező lenne). + public static long? ItemValueHuf(ShippingItem item, double rateToHuf) + { + var huf = Math.Round(ItemLineValue(item) * rateToHuf, MidpointRounding.AwayFromZero); + return huf > 0 ? (long)huf : null; + } +} diff --git a/FruitBank.Common/Services/Ekaer/ShippingToEkaerMapper.cs b/FruitBank.Common/Services/Ekaer/ShippingToEkaerMapper.cs index 7b221ff3..f2230597 100644 --- a/FruitBank.Common/Services/Ekaer/ShippingToEkaerMapper.cs +++ b/FruitBank.Common/Services/Ekaer/ShippingToEkaerMapper.cs @@ -19,6 +19,11 @@ public sealed class ShippingToEkaerMapper : IShippingToEkaerMapper /// A NAV EKÁER magyar rendszer — a „belföld" mindig HU; minden más feladó-ország import. private const string HomeCountry = "HU"; + private readonly EkaerSettings _settings; + + public ShippingToEkaerMapper(EkaerSettings settings) + => _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + public IReadOnlyList MapShipping(Shipping shipping, EkaerCompanyInfo company, OperationType operation = OperationType.Create) { ArgumentNullException.ThrowIfNull(shipping); @@ -31,11 +36,12 @@ public sealed class ShippingToEkaerMapper : IShippingToEkaerMapper foreach (var document in shipping.ShippingDocuments ?? []) { index++; + var rateToHuf = EkaerValueCalculator.ResolveRateToHuf(document.Partner?.Currency, _settings.EurHufRate); operations.Add(new TradeCardOperationType { Index = index, Operation = operation, - TradeCard = BuildTradeCard(shipping, document, company), + TradeCard = BuildTradeCard(shipping, document, company, rateToHuf), }); } @@ -47,10 +53,11 @@ public sealed class ShippingToEkaerMapper : IShippingToEkaerMapper ArgumentNullException.ThrowIfNull(document); ArgumentNullException.ThrowIfNull(company); - return BuildTradeCard(document.Shipping, document, company); + var rateToHuf = EkaerValueCalculator.ResolveRateToHuf(document.Partner?.Currency, _settings.EurHufRate); + return BuildTradeCard(document.Shipping, document, company, rateToHuf); } - private static TradeCardType BuildTradeCard(Shipping? shipping, ShippingDocument document, EkaerCompanyInfo company) + private static TradeCardType BuildTradeCard(Shipping? shipping, ShippingDocument document, EkaerCompanyInfo company, double rateToHuf) { var seller = document.Partner; // a beszállító (feladó) — ICompanyInfoBase @@ -83,7 +90,7 @@ public sealed class ShippingToEkaerMapper : IShippingToEkaerMapper if (shipping?.CargoTruck != null) tradeCard.Vehicle = BuildVehicle(shipping.CargoTruck); if (shipping?.CargoTrailer != null) tradeCard.Vehicle2 = BuildVehicle(shipping.CargoTrailer); - foreach (var item in document.ShippingItems ?? []) tradeCard.Items.Add(BuildItem(item)); + foreach (var item in document.ShippingItems ?? []) tradeCard.Items.Add(BuildItem(item, rateToHuf)); return tradeCard; } @@ -96,15 +103,15 @@ public sealed class ShippingToEkaerMapper : IShippingToEkaerMapper ? TradeType.D : TradeType.I; - private static TradeCardItemType BuildItem(ShippingItem item) => new() + private static TradeCardItemType BuildItem(ShippingItem item, double rateToHuf) => new() { 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 = item.ProductDto?.Gtin, // VTSZ — átmenetileg a Gtin oszlopban (FBANKAPP-DMODEL-I-P6X4) + 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 (HUF): a deviza/FX tisztázásáig NEM töltjük (a mező opcionális). Lásd EKAER_TODO #3, #10. + Value = EkaerValueCalculator.ItemValueHuf(item, rateToHuf), // beszerzési érték HUF-ban (Partner.Currency → árfolyam); 0/ismeretlen → null }; private static BasicVehicleDetailType BuildVehicle(CargoTruck truck) => new() @@ -163,6 +170,15 @@ public sealed class ShippingToEkaerMapper : IShippingToEkaerMapper 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) diff --git a/FruitBankHybrid.Shared.Tests/Ekaer/ShippingToEkaerMapperTests.cs b/FruitBankHybrid.Shared.Tests/Ekaer/ShippingToEkaerMapperTests.cs index 52b441fe..461ab4c9 100644 --- a/FruitBankHybrid.Shared.Tests/Ekaer/ShippingToEkaerMapperTests.cs +++ b/FruitBankHybrid.Shared.Tests/Ekaer/ShippingToEkaerMapperTests.cs @@ -15,7 +15,7 @@ namespace FruitBankHybrid.Shared.Tests.Ekaer; [TestClass] public sealed class ShippingToEkaerMapperTests { - private static readonly ShippingToEkaerMapper Mapper = new(); + private static readonly ShippingToEkaerMapper Mapper = new(new EkaerSettings()); // ---- Helpers ------------------------------------------------------------ diff --git a/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerHistory.razor b/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerHistory.razor index f47d636b..de00d48b 100644 --- a/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerHistory.razor +++ b/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerHistory.razor @@ -33,6 +33,8 @@ + @* Audit: a value-számításhoz alkalmazott árfolyam (HUF feladónál 1). ReadOnly — generáláskor töltődik. *@ + @if (!string.IsNullOrWhiteSpace(context.DisplayText)) @@ -97,7 +99,9 @@ @* A dátumválasztó editor csak Template-ben mehet toolbarba; a gomb natív DxToolbarItem, hogy a toolbar stílusát/igazítását kapja (lásd: "Ai process..." minta a GridShippingDocument-ben). *@ - + @* dxbl-toolbar-edit: a DevExpress téma saját opt-in osztálya editor-t hordozó toolbar-itemre — + ettől kapja a beépített középre igazítást és a toolbarba simuló editor-stílust. *@ +