diff --git a/.claude/hooks/db-dev-guard.ps1 b/.claude/hooks/db-dev-guard.ps1 new file mode 100644 index 00000000..4f212be8 --- /dev/null +++ b/.claude/hooks/db-dev-guard.ps1 @@ -0,0 +1,25 @@ +# Claude Code PreToolUse hook: DB-vedelem — csak _DEV nevu adatbazis erheto el shell-parancsbol. +# Bemenet: hook JSON a stdin-en; ha a parancs connection stringet tartalmaz (Initial Catalog= / Database=) +# es a DB-nev NEM tartalmazza a "_DEV"-et, a tool-hivast DENY-jal blokkolja. + +$payloadText = [Console]::In.ReadToEnd() +if ([string]::IsNullOrWhiteSpace($payloadText)) { exit 0 } + +try { $payload = $payloadText | ConvertFrom-Json } catch { exit 0 } + +$command = $payload.tool_input.command +if ([string]::IsNullOrWhiteSpace($command)) { exit 0 } + +$pattern = [regex]'(?i)(?:Initial\s+Catalog|Database)\s*=\s*([^;"''\s]+)' + +foreach ($match in $pattern.Matches($command)) { + $dbName = $match.Groups[1].Value + if ($dbName -notmatch '(?i)_DEV') { + $reason = "Blokkolt: csak _DEV adatbazis modosithato! (talalt adatbazis: $dbName)" + $result = @{ hookSpecificOutput = @{ hookEventName = 'PreToolUse'; permissionDecision = 'deny'; permissionDecisionReason = $reason } } + Write-Output ($result | ConvertTo-Json -Depth 5 -Compress) + exit 0 + } +} + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..96d98deb --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|PowerShell", + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -ExecutionPolicy Bypass -File \"H:/Applications/Mango/Source/FruitBankHybridApp/.claude/hooks/db-dev-guard.ps1\"", + "timeout": 30, + "statusMessage": "DB guard (_DEV) ellenőrzés..." + } + ] + } + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 977e2229..696d3504 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -54,7 +54,28 @@ "Bash(rm -f H:/Applications/Mango/Source/NopCommerce.Common/4.70/Plugins/Nop.Plugin.Misc.AIPlugin/Services/Ekaer/EkaerMappingOptions.cs H:/Applications/Mango/Source/NopCommerce.Common/4.70/Plugins/Nop.Plugin.Misc.AIPlugin/Services/Ekaer/IShippingToEkaerMapper.cs H:/Applications/Mango/Source/NopCommerce.Common/4.70/Plugins/Nop.Plugin.Misc.AIPlugin/Services/Ekaer/ShippingToEkaerMapper.cs)", "Bash(rmdir \"H:/Applications/Mango/Source/NopCommerce.Common/4.70/Plugins/Nop.Plugin.Misc.AIPlugin/Services/Ekaer\")", "Bash(rm -f \"H:/Applications/Mango/Source/NopCommerce.Common/4.70/Plugins/Nop.Plugin.Misc.AIPlugin/Infrastructure/NavCredentials.cs\" *)", - "Bash(rm -f \"H:/Applications/Aycode/Source/AyCode.Core/AyCode.Entities/IPostalParty.cs\" *)" + "Bash(rm -f \"H:/Applications/Aycode/Source/AyCode.Core/AyCode.Entities/IPostalParty.cs\" *)", + "PowerShell(winget install Microsoft.DotNet.Runtime.9 --architecture x64 --accept-source-agreements --accept-package-agreements)", + "PowerShell($url = \"https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.17/dotnet-runtime-9.0.17-win-x64.exe\"; $dst = \"$env:TEMP\\\\dotnet-runtime-9.0.17-win-x64.exe\"; Invoke-WebRequest -Uri $url -OutFile $dst; Get-Item $dst | Select-Object Name, Length)", + "PowerShell($p = Start-Process \"$env:TEMP\\\\dotnet-runtime-9.0.17-win-x64.exe\" -ArgumentList \"/install\",\"/quiet\",\"/norestart\" -Wait -PassThru; \"ExitCode: $\\($p.ExitCode\\)\"; dotnet --list-runtimes | Select-String \"9\\\\.0\")", + "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)", + "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)", + "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)", + "WebFetch(domain:www.ekaer-feladas.hu)", + "WebFetch(domain:net.jogtar.hu)", + "WebFetch(domain:www.itrack.hu)", + "WebFetch(domain:docplayer.hu)", + "WebFetch(domain:supportcenter.devexpress.com)", + "PowerShell($c = Get-Command claude -ErrorAction SilentlyContinue; if \\($c\\) { $c.Source; & claude --help 2>&1 | Select-Object -First 90 } else { \"claude not on PATH\" })" ] } } diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3e9260f9..ff754e68 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -131,7 +131,7 @@ Skills defined in other repos. **Two reactive skills are pre-loaded at session s Location: `AyCode.Core/.github/skills/adr-author/SKILL.md` **Invoke on explicit user request** ("let's plan X", "decide Y vs Z", "design the W module") **or proactively flag** when the conversation looks ADR-worthy (user must confirm — never auto-invoke). -- **docs-archive** — Rotate closed entries (Status: Fixed/Resolved/Won't fix/Superseded by X) from active `_ISSUES.md`, `_TODO.md`, and `LLM_PROTOCOL_DECISIONS.md` into year-bucketed archive companions (`*_.md`). Year of the Status update determines destination file. Active files retain only Open/Partially Fixed entries plus a pointer to the archives. Archive files are NOT auto-loaded — agents read them on-demand when historical context becomes relevant. +- **docs-archive** — Rotate entries from active `_ISSUES.md`, `_TODO.md`, and `LLM_PROTOCOL_DECISIONS.md` into **year-month bucket** archive companions (`*__.md`). Two rules: **(A)** `_ISSUES.md` / `_TODO.md` — only `Status: Closed` entries are archive-eligible (per `TOPIC_CODES.md` 3-value vocabulary: `Open` / `InProgress` / `Closed`); **(B)** `LLM_PROTOCOL_DECISIONS.md` — last-15-active rolling window (LLMP-DEC-67), older entries rotate by entry date. Bucket derived from a date in the entry body. Active files retain `Open` / `InProgress` entries plus a pointer to the archives. Archive files are NOT auto-loaded — agents read them on-demand when historical context becomes relevant. Location: `AyCode.Core/.github/skills/docs-archive/SKILL.md` **Invoke on explicit user request** ("archive ISSUES", "rotate logs") **or proactively flag** when an active artifact file has many closed prior entries (user must confirm — never auto-invoke). diff --git a/FruitBank.Common.Server/Services/Ekaer/FruitBankEkaerService.cs b/FruitBank.Common.Server/Services/Ekaer/FruitBankEkaerService.cs index 616cb277..23f266a0 100644 --- a/FruitBank.Common.Server/Services/Ekaer/FruitBankEkaerService.cs +++ b/FruitBank.Common.Server/Services/Ekaer/FruitBankEkaerService.cs @@ -1,5 +1,7 @@ +using AyCode.Services.Nav; using AyCode.Services.Nav.Ekaer; using AyCode.Services.Nav.Ekaer.Models; +using FruitBank.Common.Dtos; using FruitBank.Common.Entities; using FruitBank.Common.Services.Ekaer; @@ -8,20 +10,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 EkaerCompanyInfo _company; + private readonly IEkaerTradeCardValidator _validator; + private readonly IEkaerSettings _settings; - public FruitBankEkaerService(IShippingToEkaerMapper mapper, IEkaerSubmitService submitService, EkaerCompanyInfo company) + public FruitBankEkaerService(IShippingToEkaerMapper mapper, IEkaerSubmitService submitService, IEkaerTradeCardValidator validator, IEkaerSettings settings) { _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); _submitService = submitService ?? throw new ArgumentNullException(nameof(submitService)); - _company = company ?? throw new ArgumentNullException(nameof(company)); + _validator = validator ?? throw new ArgumentNullException(nameof(validator)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); } public Task SubmitShippingAsync(Shipping shipping, OperationType operation = OperationType.Create, CancellationToken cancellationToken = default) @@ -29,7 +33,75 @@ 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); } + + public EkaerHistory GenerateEkaerXmlDocument(IReadOnlyCollection documents, EkaerHistory? ekaerHistory = null) + { + ArgumentNullException.ThrowIfNull(documents); + if (documents.Count == 0) throw new ArgumentException("documents is empty", nameof(documents)); + ekaerHistory ??= new EkaerHistory { IsOutgoing = false }; + + // A csoport azonos Partneré (a kapu így csoportosít) → az első dokumentum pénzneme a mérvadó. + var currency = documents.First().Partner?.Currency; + // A csoport ÖSSZES dokumentuma EGY tradeCard-dá (összevont tömeg/érték): ToConsignment → BuildTradeCard. + var tradeCard = _mapper.BuildTradeCard(_mapper.ToConsignment(documents, _settings.Company)); + return TryConfigError(ekaerHistory, currency) ?? Finalize(ekaerHistory, tradeCard, currency); + } + + public EkaerHistory GenerateEkaerXmlDocument(OrderDto order, EkaerHistory? ekaerHistory = null) + { + ArgumentNullException.ThrowIfNull(order); + ekaerHistory ??= new EkaerHistory { IsOutgoing = true }; + + // Kimenő pénznem: jelenleg minden HUF (a deviza az OrderDto-ba kerül, amint bekötik) → ConversionRate = 1. + const string currency = "HUF"; + 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); + + public void SetSummary(EkaerHistory history, DateTime? shippingDate, string? partner) + { + history.ShippingDate = shippingDate; + history.Partner = partner; + } + + /// 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) + { + if (EkaerValueCalculator.IsHuf(currency) || _settings.EurHufRate > 0) return null; + + 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; + } + + /// Közös befejezés (bejövő/kimenő): validál, szerializál; az XML validációs hibánál IS tárolódik + /// (a detail-nézethez), és rögzíti a ténylegesen alkalmazott árfolyamot (HUF → 1, külföldi → FX-ráta). + private EkaerHistory Finalize(EkaerHistory ekaerHistory, TradeCardType tradeCard, string? currency) + { + var operation = new TradeCardOperationType { Index = 1, Operation = OperationType.Create, TradeCard = tradeCard }; + var messages = _validator.Validate(operation); + + ekaerHistory.XmlDoc = NavXmlHelper.Serialize(tradeCard); + ekaerHistory.ConversionRate = EkaerValueCalculator.ResolveRateToHuf(currency, _settings.EurHufRate); + // Blokkoló hiba → ValidationError (nem küldhető); csak warning → GeneratedWithWarning (küldhető, de pótlandó); + // semmi → Generated. Az üzeneteket súlyossággal prefixeljük, hogy a detail-nézet megkülönböztesse őket. + ekaerHistory.Status = messages.HasErrors() ? EkaerStatus.ValidationError + : messages.HasWarnings() ? EkaerStatus.GeneratedWithWarning + : EkaerStatus.Generated; + // Error-ok elöl, warningok hátul (a detail-nézet soronként színez); a prefix [Error]/[Warning] hordozza a szintet. + ekaerHistory.ErrorText = messages.Count == 0 ? null + : string.Join(Environment.NewLine, messages + .OrderByDescending(m => m.GetSeverity()) + .Select(m => $"[{m.GetSeverity()}] {m.ErrorMessage}")); + return ekaerHistory; + } } diff --git a/FruitBank.Common.Server/Services/Ekaer/IFruitBankEkaerService.cs b/FruitBank.Common.Server/Services/Ekaer/IFruitBankEkaerService.cs index d9f13b9c..a6f9619f 100644 --- a/FruitBank.Common.Server/Services/Ekaer/IFruitBankEkaerService.cs +++ b/FruitBank.Common.Server/Services/Ekaer/IFruitBankEkaerService.cs @@ -1,6 +1,8 @@ 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; @@ -16,4 +18,36 @@ public interface IFruitBankEkaerService /// vagy a NAV-válasz — lásd . /// Task SubmitShippingAsync(Shipping shipping, OperationType operation = OperationType.Create, CancellationToken cancellationToken = default); + + /// + /// Egy szállítólevél-CSOPORTBÓL (egy EKÁER-egység dokumentumai) legenerálja az EKÁER tradeCard XML-t és validálja — a (meglévő vagy új) + /// rekordot tölti: XmlDoc + Status + /// ( / ) + ErrorText. + /// NEM perzisztál és NEM hív NAV-ot — a mentés (upsert) a hívó SignalR endpoint dolga. + /// + /// A deklarációhoz tartozó szállítólevél-CSOPORT (egy (Shipping, Partner, PartnerDepot) egység) a betöltött gráffal (Partner, Items+ProductDto, Shipping→járművek) — összevont tömeggel/értékkel EGY tradeCard. + /// A csoport meglévő rekordja (újrageneráláskor); null → új rekord készül. + EkaerHistory GenerateEkaerXmlDocument(IReadOnlyCollection documents, EkaerHistory? ekaerHistory = null); + + /// + /// Egy kimenő rendelésből () legenerálja az EKÁER tradeCard XML-t és validálja — + /// a (meglévő vagy új) rekordot tölti (IsOutgoing = true). Mi vagyunk az + /// 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); + + /// Beállítja a deklaráció () összegző mezőit (DB-mentes): ShippingDate + Partner. + /// A csoporton belül invariánsak; a forrás-kinyerés (bejövő: doc.ShippingDate + Partner.Name; kimenő: order.DateOfReceipt + + /// Customer.Company) a hívóé. Fallback NINCS — null/üres marad, ha hiányzik. + void SetSummary(EkaerHistory history, DateTime? shippingDate, string? partner); } 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/Entities/EkaerHistory.cs b/FruitBank.Common/Entities/EkaerHistory.cs new file mode 100644 index 00000000..e9a9e26b --- /dev/null +++ b/FruitBank.Common/Entities/EkaerHistory.cs @@ -0,0 +1,155 @@ +using System.Text.Json.Serialization; +using AyCode.Core.Serializers.Attributes; +using AyCode.Core.Serializers.Toons; +using AyCode.Interfaces.TimeStampInfo; +using LinqToDB; +using LinqToDB.Mapping; +using Mango.Nop.Core.Entities; + + +namespace FruitBank.Common.Entities; + +[AcBinarySerializable(false, true, false, true, false, false)] +[ToonDescription("NAV EKÁER declaration lifecycle record", Purpose = "Work-queue and audit row for one EKÁER road-freight declaration. One declaration covers one or more source records via EkaerHistoryMapping (the link lives in that junction table, NOT here): inbound, the ShippingDocuments of one (Shipping, Partner, PartnerDepot) group aggregated into a single tradeCard; outbound, a single completed Order. Tracks the declaration through generation, validation and submission to the Hungarian tax authority (NAV).")] +[Table(Name = FruitBankConstClient.EkaerHistoryDbTableName)] +[System.ComponentModel.DataAnnotations.Schema.Table(FruitBankConstClient.EkaerHistoryDbTableName)] + +public sealed class EkaerHistory: MgEntityBase, ITimeStampInfo +{ + [ToonDescription(Purpose = "Direction of the goods movement: false = incoming shipment (Shipping), true = outgoing delivery (Order).")] + public bool IsOutgoing { get; set; } + + [ToonDescription(Purpose = "Declaration-level date snapshot (group-invariant): inbound = the ShippingDocument's ShippingDate, outbound = the Order's DateOfReceipt. Set at create, refreshed on Generate. NO fallback — null if missing (the data gap is visible in the grid).")] + public DateTime? ShippingDate { get; set; } + + [ToonDescription(Purpose = "Declaration-level other-party name snapshot (group-invariant): inbound = the supplier Partner.Name, outbound = the buyer Customer.Company. Set at create, refreshed on Generate. NO fallback — null/empty if missing.")] + public string? Partner { get; set; } + + [ToonDescription(Purpose = "Lifecycle state of the declaration, stored as int (see EkaerStatus): 0 Pending (auto-created, not yet generated), 1 Generated (tradeCard XML produced and valid), 2 ValidationError (generation produced errors, see ErrorText), 3 Sent (accepted by NAV, EkaerNumber filled), 4 SendError (NAV call failed, see ErrorText).")] + public int StatusId { get; set; } + + /// A enum-nézete. A tárolt érték az int oszlop (StatusId) — a linq2db réteg + /// az enum-property-t nem perzisztálta (az oszlop kimaradt az insertből, a DB default írt 0-t), ezért a nop-minta: + /// int oszlop + nem-mappelt enum wrapper. + [NotColumn, System.ComponentModel.DataAnnotations.Schema.NotMapped, Newtonsoft.Json.JsonIgnore, JsonIgnore] + public EkaerStatus Status + { + get => (EkaerStatus)StatusId; + set => StatusId = (int)value; + } + + [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; } + + [ToonDescription(Purpose = "When the declaration was successfully submitted to NAV. Null until sent.")] + public DateTime? SentDate { get; set; } + + [ToonDescription(Purpose = "Validation or NAV submission error details for the ValidationError / SendError states. Null when the last operation succeeded.")] + public string? ErrorText { get; set; } + + /// A deklarációhoz tartozó forrás-rekordok (EkaerHistoryMapping): bejövő → N szállítólevél (ShippingDocument.Id), + /// kimenő → 1 rendelés (Order.Id). NEM auto-loadol — a DbTable GetAll(loadRelations: true) tölti (LoadWith). + [Association(ThisKey = nameof(Id), OtherKey = nameof(EkaerHistoryMapping.EkaerHistoryId), CanBeNull = true)] + public List? Mappings { get; set; } + + public DateTime Created { get; set; } + public DateTime Modified { get; set; } +} + +/// Az EKÁER-bejelentés életciklus + kimenet állapota. Append-only: új érték a végére, meglévő értéke nem +/// változhat (DB-ben int-ként tárolt). Az állapot egyetlen mező; a kategorizálás a +/// segédekkel megy (külön oszlop nélkül) — a folyamat lineáris, ezért a single-enum állapotgép a tiszta illeszkedés. +public enum EkaerStatus +{ + /// Automatikusan létrejött, még nem volt Generate. + Pending = 0, + /// A tradeCard XML legenerálva és valid — küldhető. + Generated = 1, + /// A Generate BLOKKOLÓ (error) hibákkal zárult (ErrorText) — a forrásadat javítandó, nem küldhető. + ValidationError = 2, + /// NAV által befogadva (EkaerNumber + SentDate töltve), hiánytalanul. + Sent = 3, + /// A NAV-hívás hibával zárult (ErrorText) — újraküldhető. + SendError = 4, + /// Legenerálva és KÜLDHETŐ, de warninggal (pl. hiányzó rendszám, ami a felrakodás megkezdéséig pótolható). + GeneratedWithWarning = 5, + /// NAV-hoz beküldve, de PÓTLÁSRA VÁR (pl. a rendszám a felrakodás megkezdéséig megadandó). + SentWithMissingData = 6, +} + +/// Állapot-kategóriák — a tárolás egy mező (Status), a csoportosítás ezekkel (külön oszlop nélkül). +public static class EkaerStatusExtensions +{ + /// Blokkoló hibás állapot (a bejelentés nem küldhető / a NAV-hívás bukott). + public static bool IsError(this EkaerStatus status) => status is EkaerStatus.ValidationError or EkaerStatus.SendError; + + /// Beküldhető: legenerálva, blokkoló hiba nélkül (warninggal is) — innen mehet a NAV-hoz. + public static bool IsSubmittable(this EkaerStatus status) => status is EkaerStatus.Generated or EkaerStatus.GeneratedWithWarning; + + /// A NAV-nál van (akár hiányosan). + public static bool IsSent(this EkaerStatus status) => status is EkaerStatus.Sent or EkaerStatus.SentWithMissingData; + + /// Elküldve, de pótlásra vár (hiányos adat — pl. rendszám). + public static bool NeedsCompletion(this EkaerStatus status) => status is EkaerStatus.SentWithMissingData; +} + +/// EkaerHistory-lekérdezés szűrő a tabokhoz, . A kategóriák diszjunkt +/// StatusId-partíciók, ezért kombinálhatók (pl. ToSubmit | NeedsCompletion = minden, ami még nincs lezárva). +/// = 0 (nincs bit = nincs szűrő → minden), egyúttal a biztonságos default; szándékosan NEM nullable. +/// Vigyázat: HasFlag(All) mindig true (0 bit), ezért a szerver előbb a == All ágat nézi. +[Flags] +public enum EkaerHistoryFilter +{ + /// Minden rekord (nincs szűrés). 0 = nincs bit → minden; egyúttal a biztonságos default. + All = 0, + /// Beküldésre váró: minden, ami még NINCS a NAV-nál (Pending/Generated/GeneratedWithWarning/ValidationError/SendError). + ToSubmit = 1, + /// Elküldött és hiánytalan (). + Sent = 2, + /// Elküldve, de pótlásra vár (). + NeedsCompletion = 4, +} + +//public sealed class EkaerHistoryShipping : EkaerHistoryBase +//{ +// public int ShippingId +// { +// get => ForeignItemId; +// set => ForeignItemId = value; +// } + +// [Association(ThisKey = nameof(ShippingId), OtherKey = nameof(Shipping.Id), CanBeNull = true)] +// public Shipping? Shipping { get; set; } +//} + +//public sealed class EkaerHistoryOrder : EkaerHistoryBase +//{ +// public int ShippingId +// { +// get => ForeignItemId; +// set => ForeignItemId = value; +// } + +// [Association(ThisKey = nameof(ShippingId), OtherKey = nameof(Shipping.Id), CanBeNull = true)] +// public Shipping? Shipping { get; set; } +//} + +//public abstract class EkaerHistoryBase : MgEntityBase, ITimeStampInfo +//{ +// [NotColumn] +// protected int ForeignItemId; + +// [NotColumn] +// [ToonDescription(BusinessRule = "get => ForeignItemId", Constraints = "[#SmartTypeConstraints]")] +// public int ForeignKey => ForeignItemId; + +// public DateTime Created { get; set; } +// public DateTime Modified { get; set; } +//} \ No newline at end of file diff --git a/FruitBank.Common/Entities/EkaerHistoryMapping.cs b/FruitBank.Common/Entities/EkaerHistoryMapping.cs new file mode 100644 index 00000000..614fd7e4 --- /dev/null +++ b/FruitBank.Common/Entities/EkaerHistoryMapping.cs @@ -0,0 +1,29 @@ +using AyCode.Core.Serializers.Attributes; +using AyCode.Core.Serializers.Toons; +using AyCode.Interfaces.TimeStampInfo; +using LinqToDB.Mapping; +using Mango.Nop.Core.Entities; + +namespace FruitBank.Common.Entities; + +[AcBinarySerializable(false, true, false, true, false, false)] +[ToonDescription("EkaerHistory ↔ source document/order junction (1:N)", Purpose = "Junction for the EKÁER declaration ↔ source relationship: one EkaerHistory can cover several inbound ShippingDocuments (grouped by Shipping + PartnerDepot) or a single outbound Order. ForeignKey is the source entity Id, interpreted via the parent EkaerHistory.IsOutgoing. Keeps the EKÁER link OUT of the core ShippingDocument/Order tables, so declarations can be deleted and regenerated without touching system data.")] +[Table(Name = FruitBankConstClient.EkaerHistoryMappingDbTableName)] +[System.ComponentModel.DataAnnotations.Schema.Table(FruitBankConstClient.EkaerHistoryMappingDbTableName)] +public sealed class EkaerHistoryMapping : MgEntityBase, ITimeStampInfo +{ + [ToonDescription(Purpose = "The owning EKÁER declaration (EkaerHistory.Id).")] + public int EkaerHistoryId { get; set; } + + [ToonDescription(Purpose = "Id of the covered source entity: ShippingDocument.Id when the parent EkaerHistory.IsOutgoing is false, Order id when true.")] + public int ForeignKey { get; set; } + + [ToonDescription(Purpose = "Reserved for future PER-DOCUMENT detail. Currently NOT populated — the declaration-level summary (date + other party), being group-invariant, lives on EkaerHistory.ShippingDate + EkaerHistory.Partner instead. The column is kept for later document-specific notes.")] + public string? Comment { get; set; } + + [Association(ThisKey = nameof(EkaerHistoryId), OtherKey = nameof(EkaerHistory.Id), CanBeNull = true)] + public EkaerHistory? EkaerHistory { get; set; } + + public DateTime Created { get; set; } + public DateTime Modified { get; set; } +} diff --git a/FruitBank.Common/Entities/Partner.cs b/FruitBank.Common/Entities/Partner.cs index f4793dee..d8e492ce 100644 --- a/FruitBank.Common/Entities/Partner.cs +++ b/FruitBank.Common/Entities/Partner.cs @@ -2,7 +2,6 @@ using AyCode.Core.Serializers.Toons; using FruitBank.Common.Interfaces; using LinqToDB.Mapping; -using Mango.Nop.Core.Entities; namespace FruitBank.Common.Entities; @@ -12,6 +11,12 @@ namespace FruitBank.Common.Entities; [System.ComponentModel.DataAnnotations.Schema.Table(FruitBankConstClient.PartnerDbTableName)] public sealed class Partner : PartnerBase, IPartner { - [Association(ThisKey = nameof(Id), OtherKey = nameof(ShippingDocument.ShippingId), CanBeNull = true)] + [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; } + + [Association(ThisKey = nameof(Id), OtherKey = nameof(ShippingDocument.PartnerId), CanBeNull = true)] public List? ShippingDocuments { get; set; } } \ No newline at end of file diff --git a/FruitBank.Common/Entities/PartnerDepot.cs b/FruitBank.Common/Entities/PartnerDepot.cs new file mode 100644 index 00000000..94f91a41 --- /dev/null +++ b/FruitBank.Common/Entities/PartnerDepot.cs @@ -0,0 +1,38 @@ +using AyCode.Core.Interfaces; +using AyCode.Core.Serializers.Attributes; +using AyCode.Interfaces.TimeStampInfo; +using LinqToDB.Mapping; +using Mango.Nop.Core.Entities; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; + +namespace FruitBank.Common.Entities; + +[AcBinarySerializable(false, true, false, true, false, false)] +//[ToonDescription("Business partner with address and tax information", Purpose = "Represents an external legal entity, specifically a Supplier who provides goods or a business partner involved in the procurement chain")] +[LinqToDB.Mapping.Table(Name = FruitBankConstClient.PartnerDepotDbTableName)] +[System.ComponentModel.DataAnnotations.Schema.Table(FruitBankConstClient.PartnerDepotDbTableName)] +public sealed class PartnerDepot : MgEntityBase, ITimeStampInfo, ICompanyInfoBase +{ + public int PartnerId { get; set; } + + public string Name { get; set;} + + [NotColumn, NotMapped, Newtonsoft.Json.JsonIgnore, JsonIgnore] + public string? TaxId => Partner?.TaxId; + + public string CountryCode { get; set; } + public string PostalCode { get; set;} + public string City { get; set;} + public string Street { get; set;} + + [NotColumn, NotMapped, Newtonsoft.Json.JsonIgnore, JsonIgnore] + public string FullAddress => this.ComposeFullAddress() ?? string.Empty; + + [Association(ThisKey = nameof(PartnerId), OtherKey = nameof(Partner.Id), CanBeNull = true)] + public Partner? Partner { get; set; } + + [SkipValuesOnUpdate] + public DateTime Created { get; set; } + public DateTime Modified { get; set; } +} \ No newline at end of file diff --git a/FruitBank.Common/Entities/ShippingDocument.cs b/FruitBank.Common/Entities/ShippingDocument.cs index e75ff104..56ff0450 100644 --- a/FruitBank.Common/Entities/ShippingDocument.cs +++ b/FruitBank.Common/Entities/ShippingDocument.cs @@ -15,6 +15,7 @@ public sealed class ShippingDocument : MgEntityBase, IShippingDocument { public int PartnerId { get; set; } public int? ShippingId { get; set; } + public int? PartnerDepotId { get; set; } public string DocumentIdNumber { get; set; } public string PdfFileName { get; set; } @@ -32,6 +33,12 @@ public sealed class ShippingDocument : MgEntityBase, IShippingDocument [Association(ThisKey = nameof(PartnerId), OtherKey = nameof(Partner.Id), CanBeNull = true)] public Partner? Partner { get; set; } + // Csak az EKÁER felrakodási címhez kell. SZÁNDÉKOSAN nincs benne semelyik alapértelmezett loadRelations-ben: + // kizárólag a generálás/reconciliation kéri explicit `LoadWith(sd => sd.PartnerDepot)`-tal, így a többi + // lekérdezést/sorosítást nem terheli (betöltetlenül null marad). Legacy szállítólevélnél PartnerDepotId = null. + [Association(ThisKey = nameof(PartnerDepotId), OtherKey = nameof(PartnerDepot.Id), CanBeNull = true)] + public PartnerDepot? PartnerDepot { get; set; } + [Association(ThisKey = nameof(Id), OtherKey = nameof(ShippingItem.ShippingDocumentId), CanBeNull = true)] public List? ShippingItems { get; set; } diff --git a/FruitBank.Common/FruitBankConstClient.cs b/FruitBank.Common/FruitBankConstClient.cs index 3d3c59f1..9523db92 100644 --- a/FruitBank.Common/FruitBankConstClient.cs +++ b/FruitBank.Common/FruitBankConstClient.cs @@ -30,6 +30,10 @@ public static class FruitBankConstClient public const string PalletDbTableName = "fbPallet"; public const string FilesDbTableName = "fbFiles"; public const string PartnerDbTableName = "fbPartner"; + public const string PartnerDepotDbTableName = "fbPartnerDepot"; + + public const string EkaerHistoryDbTableName = "fbEkaerHistory"; + public const string EkaerHistoryMappingDbTableName = "fbEkaerHistoryMapping"; public const string OrderItemPalletDbTableName = "fbOrderItemPallet"; diff --git a/FruitBank.Common/Interfaces/IFruitBankDataControllerCommon.cs b/FruitBank.Common/Interfaces/IFruitBankDataControllerCommon.cs index 39a8ae26..23fe64e0 100644 --- a/FruitBank.Common/Interfaces/IFruitBankDataControllerCommon.cs +++ b/FruitBank.Common/Interfaces/IFruitBankDataControllerCommon.cs @@ -20,6 +20,26 @@ public interface IFruitBankDataControllerCommon public Task UpdatePartner(Partner partner); #endregion Partner + #region PartnerDepot + public Task?> GetPartnerDepots(); + public Task GetPartnerDepotById(int id); + public Task?> GetPartnerDepotsByPartnerId(int partnerId); + public Task AddPartnerDepot(PartnerDepot partnerDepot); + public Task UpdatePartnerDepot(PartnerDepot partnerDepot); + #endregion PartnerDepot + + #region EkaerHistory + public Task?> GetEkaerHistories(EkaerHistoryFilter filter); + public Task GetEkaerHistoryById(int id); + public Task?> GetEkaerHistoriesByForeignKey(int foreignKey); + public Task AddEkaerHistory(EkaerHistory ekaerHistory); + public Task UpdateEkaerHistory(EkaerHistory ekaerHistory); + public Task GenerateEkaerXmlDocument(int ekaerHistoryId); + public Task CreateEkaerHistory(int foreignKey, bool isOutgoing); + public Task CreateMissingEkaerHistories(DateTime fromDate); + public Task GetEkaerHistoryCount(EkaerHistoryFilter filter); + #endregion EkaerHistory + #region CargoPartner public Task?> GetCargoPartners(); public Task GetCargoPartnerById(int id); diff --git a/FruitBank.Common/Interfaces/IPartner.cs b/FruitBank.Common/Interfaces/IPartner.cs index 5518e234..5dec94af 100644 --- a/FruitBank.Common/Interfaces/IPartner.cs +++ b/FruitBank.Common/Interfaces/IPartner.cs @@ -13,6 +13,8 @@ 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/Interfaces/IShippingDocument.cs b/FruitBank.Common/Interfaces/IShippingDocument.cs index 7a8a997d..cdc12125 100644 --- a/FruitBank.Common/Interfaces/IShippingDocument.cs +++ b/FruitBank.Common/Interfaces/IShippingDocument.cs @@ -9,7 +9,8 @@ public interface IShippingDocument: IEntityInt, ITimeStampInfo//, IMeasured { public int PartnerId { get; set; } public int? ShippingId { get; set; } - + public int? PartnerDepotId { get; set; } + public string DocumentIdNumber { get; set; } public string PdfFileName { get; set; } 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/EkaerSettings.cs b/FruitBank.Common/Services/Ekaer/EkaerSettings.cs new file mode 100644 index 00000000..9b04665c --- /dev/null +++ b/FruitBank.Common/Services/Ekaer/EkaerSettings.cs @@ -0,0 +1,29 @@ +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 : IEkaerSettings +{ + /// 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é). + /// int: a legmagasabb küszöb 5 millió Ft, bőven belefér. + public int 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..3a674f9f --- /dev/null +++ b/FruitBank.Common/Services/Ekaer/EkaerValueCalculator.cs @@ -0,0 +1,54 @@ +using FruitBank.Common.Dtos; +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; + } + + // ── Kimenő (Order) változatok ──────────────────────────────────────────── + // Az érték a NETTÓ (ÁFA nélküli) egységár × mennyiség — az EKÁER „nettó érték"-et vár. + + /// Egy rendelés-tétel nettó értéke a számla pénznemében (UnitPriceExclTax × mennyiség), átváltás előtt. + public static double ItemLineValue(OrderItemDto item) => (double)item.UnitPriceExclTax * item.Quantity; + + /// Egy rendelés-tétel értéke HUF-ban, egészre kerekítve. 0/ismeretlen → null (lásd a bejövő párját). + public static long? ItemValueHuf(OrderItemDto 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/IShippingToEkaerMapper.cs b/FruitBank.Common/Services/Ekaer/IShippingToEkaerMapper.cs index a3430daf..ca2335b9 100644 --- a/FruitBank.Common/Services/Ekaer/IShippingToEkaerMapper.cs +++ b/FruitBank.Common/Services/Ekaer/IShippingToEkaerMapper.cs @@ -1,5 +1,6 @@ using AyCode.Services.Nav.Ekaer; using AyCode.Services.Nav.Ekaer.Models; +using FruitBank.Common.Dtos; using FruitBank.Common.Entities; namespace FruitBank.Common.Services.Ekaer; @@ -22,4 +23,31 @@ public interface IShippingToEkaerMapper /// A bejelentő saját cégadatai (címzett bejövő relációban) + a lerakodási hely. /// A tradeCard művelet típusa. Alapértelmezés: . IReadOnlyList MapShipping(Shipping shipping, EkaerCompanyInfo company, OperationType operation = OperationType.Create); + + /// + /// EGY szállítólevelet () képez le egy tradeCard-dá — a dokumentum-szintű + /// EKÁER-granularitás egysége (1 dokumentum = 1 tradeCard = 1 TCN). A jármű/fuvarozó adatok a + /// document.Shipping-ből jönnek; ha az nincs betöltve, ezek üresen maradnak (a validátor jelzi). + /// + TradeCardType MapDocument(ShippingDocument document, EkaerCompanyInfo company); + + /// + /// EGY kimenő rendelést () képez le egy tradeCard-dá: a bejelentő + /// (mi) az ELADÓ, a vevő a CÍMZETT, az irány belföldi értékesítés (D/S). A vonó jármű forrása + /// 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 c038c745..44b6d51b 100644 --- a/FruitBank.Common/Services/Ekaer/ShippingToEkaerMapper.cs +++ b/FruitBank.Common/Services/Ekaer/ShippingToEkaerMapper.cs @@ -2,7 +2,9 @@ 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; @@ -10,15 +12,28 @@ 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). + 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); @@ -30,86 +45,202 @@ 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++; operations.Add(new TradeCardOperationType { Index = index, Operation = operation, - TradeCard = BuildTradeCard(shipping, document, company), + TradeCard = BuildTradeCard(ToConsignment([document], company)), }); } return operations; } - private static TradeCardType BuildTradeCard(Shipping shipping, ShippingDocument document, EkaerCompanyInfo company) + public TradeCardType MapDocument(ShippingDocument document, EkaerCompanyInfo company) { - var seller = document.Partner; // a beszállító (feladó) — ICompanyInfoBase + 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)-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 = 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, + UnloadLocation = BuildCustomerLocation(customer), + Lines = lines, + // Kimenőnél NEM töltjük a szállítmányozót: a vevő maga viszi el, és a vevő MÁR a címzett (destinationName) — + // külön fuvarozót nem tartunk nyilván; a carrierText opcionális (a NAV nem követeli) → üresen hagyjuk. + CarrierName = null, + // 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. + }; + } + + // ── 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(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.UnloadLocation, - 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)); + 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) => 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 = 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. + 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, }; - private static BasicVehicleDetailType BuildVehicle(CargoTruck truck) => 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() { - PlateNumber = NormalizePlateNumber(truck.LicencePlate), - Country = NormalizeCountryCode(truck.CountryCode, 3), + Name = partner?.Name, + VatNumber = partner?.TaxId, + CountryCode = partner?.CountryCode, + FullAddress = partner?.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) + 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 @@ -123,6 +254,48 @@ public sealed class ShippingToEkaerMapper : IShippingToEkaerMapper }; } + /// 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), + }; + } + + /// 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) { @@ -155,6 +328,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/FruitBank.Common/SignalRs/SignalRTags.cs b/FruitBank.Common/SignalRs/SignalRTags.cs index 298ca2ef..d62c2ec3 100644 --- a/FruitBank.Common/SignalRs/SignalRTags.cs +++ b/FruitBank.Common/SignalRs/SignalRTags.cs @@ -112,6 +112,21 @@ public class SignalRTags : AcSignalRTags public const int GetStockTakingItemsByStockTakingId = 178; public const int AddOrUpdateMeasuredStockTakingItemPallet = 179; + public const int GetPartnerDepots = 180; + public const int GetPartnerDepotById = 181; + public const int GetPartnerDepotsByPartnerId = 182; + public const int AddPartnerDepot = 183; + public const int UpdatePartnerDepot = 184; + + public const int GetEkaerHistories = 185; + public const int GetEkaerHistoryById = 186; + public const int GetEkaerHistoriesByForeignKey = 187; + public const int AddEkaerHistory = 188; + public const int UpdateEkaerHistory = 189; + public const int GenerateEkaerXmlDocument = 190; + public const int CreateEkaerHistory = 191; + public const int CreateMissingEkaerHistories = 192; + public const int GetEkaerHistoryCount = 193; public const int AuthenticateUser = 195; public const int RefreshToken = 200; diff --git a/FruitBank.Common/docs/DATAMODEL/DATAMODEL_ISSUES.md b/FruitBank.Common/docs/DATAMODEL/DATAMODEL_ISSUES.md index f6d1c55a..bcb5598e 100644 --- a/FruitBank.Common/docs/DATAMODEL/DATAMODEL_ISSUES.md +++ b/FruitBank.Common/docs/DATAMODEL/DATAMODEL_ISSUES.md @@ -29,9 +29,10 @@ A `PartnerBase` (és így `Partner` / `CargoPartner`) + a `CargoTruck` a nopComm ## FBANKAPP-DMODEL-I-P6X4: A `Product.Gtin` átmenetileg a VTSZ-t tárolja — szétválasztandó -**Status:** Open · **Priority:** P3 · **Type:** I (adatmodell / átmeneti megoldás) +**Status:** Closed (2026-06-15) — superseded by `MGFBANKPLUG-EKAER-I-T3X8` · **Priority:** P3 · **Type:** I (adatmodell / átmeneti megoldás) -> **Supersedes** `MGFBANKPLUG-EKAER-I-T3X8` — áthelyezve az EKÁER-topicból ide, mert a GTIN/VTSZ szétválasztás **általános** adatmodell-kérdés (a GTIN globális termékazonosító, a VTSZ vámtarifaszám), nem EKÁER-specifikus. +### Resolution +**Visszahelyezve** az EKÁER-topicba: **`MGFBANKPLUG-EKAER-I-T3X8`** (`docs/EKAER/EKAER_ISSUES.md`, újra **Open**). A 2026-06-02-i ide-helyezés **visszavonva** — a VTSZ-forrás (`Product.Gtin`) szétválasztását a konkrét EKÁER-leképezéshez kötve, ott követjük (egy tracker). Az alábbi leírás referencia. Az EKÁER `tradeCardItem.productVtsz` (kötelező, 8 jegyű vámtarifaszám) forrása jelenleg a nopCommerce **`Product.Gtin`** oszlop (a `ProductDto.Gtin`-en keresztül). A GTIN és a VTSZ **fogalmilag különböző**: - **GTIN** — globális kereskedelmi cikkszám (vonalkód-azonosító, EAN/UPC). 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 52b441fe..cf6f52c0 100644 --- a/FruitBankHybrid.Shared.Tests/Ekaer/ShippingToEkaerMapperTests.cs +++ b/FruitBankHybrid.Shared.Tests/Ekaer/ShippingToEkaerMapperTests.cs @@ -15,7 +15,9 @@ namespace FruitBankHybrid.Shared.Tests.Ekaer; [TestClass] public sealed class ShippingToEkaerMapperTests { - private static readonly ShippingToEkaerMapper Mapper = new(); + // EurHufRate > 0 kell: a teszt-partnernek nincs Currency-je (null → külföldiként kezelt), és árfolyam nélkül + // a value-számítás ResolveRateToHuf-ja dobna. A value-t a tesztek nem ellenőrzik; ez csak az átváltáshoz kell. + private static readonly ShippingToEkaerMapper Mapper = new(new EkaerSettings { EurHufRate = 356 }); // ---- Helpers ------------------------------------------------------------ @@ -67,18 +69,42 @@ public sealed class ShippingToEkaerMapperTests PostalCode = "1102", City = "Budapest", Street = "Raktar utca 5", - UnloadLocation = new LocationType + Site = new LocationType { Name = "FruitBank Raktár", VatNumber = "98765432-2-41", + Phone = "+36301234567", // NAV-formátum: + / 06 prefix kötelező + Email = "raktar@fruitbank.hu", Country = "HU", - ZipCode = "1102", + ZipCode = "1239", City = "Budapest", - Street = "Raktar utca", - StreetNumber = "5", + Street = "Nagykőrösi út", + StreetNumber = "353", }, }; + 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] @@ -166,7 +192,7 @@ public sealed class ShippingToEkaerMapperTests Assert.AreEqual("FruitBank Kft", tradeCard.DestinationName); Assert.AreEqual("HU", tradeCard.DestinationCountry); StringAssert.Contains(tradeCard.DestinationAddress, "Budapest", "a destinationAddress a company FullAddress-éből jön"); - Assert.AreSame(company.UnloadLocation, tradeCard.UnloadLocation, "a lerakodási hely a cégadatból jön"); + Assert.AreSame(company.Site, tradeCard.UnloadLocation, "a lerakodási hely a cég telephelyéből jön"); } [TestMethod] @@ -212,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.Tests/FruitBankClientTests.cs b/FruitBankHybrid.Shared.Tests/FruitBankClientTests.cs index ed23b765..1d2fe8fe 100644 --- a/FruitBankHybrid.Shared.Tests/FruitBankClientTests.cs +++ b/FruitBankHybrid.Shared.Tests/FruitBankClientTests.cs @@ -34,149 +34,6 @@ namespace FruitBankHybrid.Shared.Tests _signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankClientTests)); } - #region Partner - - [TestMethod] - public async Task GetPartnersTest() - { - var partners = await _signalRClient.GetPartners(); - - Assert.IsNotNull(partners); - Assert.IsNotEmpty(partners); - } - - //[TestMethod] - //[DataRow(1)] - public async Task GetPartnerByIdTest(int partnerId) - { - var partner = await _signalRClient.GetPartnerById(partnerId); - - Assert.IsNotNull(partner); - Assert.AreEqual(partnerId, partner.Id); - - return partner; - } - - [TestMethod] - [DataRow(2)] - public async Task UpdatePartnerTest(int partnerId) - { - var partner = await GetPartnerByIdTest(partnerId); - - var newName = GetFixtureName(partner.Name); - - partner.Name = newName; - partner = await _signalRClient.UpdatePartner(partner); - - Assert.IsNotNull(partner); - Assert.IsTrue(partner.Name == newName); - - partner.Name = GetOriginalName(partner.Name); - partner = await _signalRClient.UpdatePartner(partner); - - Assert.IsNotNull(partner); - Assert.AreEqual(partnerId, partner.Id); - } - #endregion Partner - - #region CargoPartner - [TestMethod] - public async Task GetCargoPartnersTest() - { - var partners = await _signalRClient.GetCargoPartners(); - - Assert.IsNotNull(partners); - Assert.IsNotEmpty(partners); - } - //[TestMethod] - //[DataRow(1)] - public async Task GetCargoPartnerByIdTest(int cargoPartnerId) - { - var cargoPartner = await _signalRClient.GetCargoPartnerById(cargoPartnerId); - - Assert.IsNotNull(cargoPartner); - Assert.AreEqual(cargoPartnerId, cargoPartner.Id); - - return cargoPartner; - } - - [TestMethod] - [DataRow(1)] - public async Task UpdateCargoPartnerTest(int cargoPartnerId) - { - var cargoPartner = await GetCargoPartnerByIdTest(cargoPartnerId); - - var newName = GetFixtureName(cargoPartner.Name); - - cargoPartner.Name = newName; - cargoPartner = await _signalRClient.UpdateCargoPartner(cargoPartner); - - Assert.IsNotNull(cargoPartner); - Assert.IsTrue(cargoPartner.Name == newName); - - cargoPartner.Name = GetOriginalName(cargoPartner.Name); - cargoPartner = await _signalRClient.UpdateCargoPartner(cargoPartner); - - Assert.IsNotNull(cargoPartner); - Assert.AreEqual(cargoPartnerId, cargoPartner.Id); - } - #endregion CargoPartner - - #region CargoTruck - [TestMethod] - public async Task GetCargoTrucksTest() - { - var cargoTrucks = await _signalRClient.GetCargoTrucks(); - - Assert.IsNotNull(cargoTrucks); - Assert.IsNotEmpty(cargoTrucks); - } - - //[TestMethod] - //[DataRow(1)] - public async Task GetCargoTruckByIdTest(int cargoTruckId) - { - var cargoTruck = await _signalRClient.GetCargoTruckById(cargoTruckId); - - Assert.IsNotNull(cargoTruck); - Assert.AreEqual(cargoTruckId, cargoTruck.Id); - - return cargoTruck; - } - - [TestMethod] - [DataRow(1)] - public async Task GetCargoTrucksByCargoPartnerIdTest(int cargoPartnerId) - { - var cargoTrucks = await _signalRClient.GetCargoTrucksByCargoPartnerId(cargoPartnerId); - - Assert.IsNotNull(cargoTrucks); - Assert.IsNotEmpty(cargoTrucks); - } - - - [TestMethod] - [DataRow(1)] - public async Task UpdateCargoTruckTest(int cargoTruckId) - { - var cargoTruck = await GetCargoTruckByIdTest(cargoTruckId); - - var newLicencePlate = GetFixtureName(cargoTruck.LicencePlate); - - cargoTruck.LicencePlate = newLicencePlate; - cargoTruck = await _signalRClient.UpdateCargoTruck(cargoTruck); - - Assert.IsNotNull(cargoTruck); - Assert.IsTrue(cargoTruck.LicencePlate == newLicencePlate); - - cargoTruck.LicencePlate = GetOriginalName(cargoTruck.LicencePlate); - cargoTruck = await _signalRClient.UpdateCargoTruck(cargoTruck); - - Assert.IsNotNull(cargoTruck); - Assert.AreEqual(cargoTruckId, cargoTruck.Id); - } - #endregion CargoTruck - #region Shipping [TestMethod] public async Task GetShippingsTest() @@ -299,7 +156,7 @@ namespace FruitBankHybrid.Shared.Tests else { Assert.IsNotNull(shippingItem.ShippingDocument); - Assert.IsTrue(shippingItem.ShippingDocument.ShippingDocumentToFiles?.Count == 0); + Assert.AreEqual(0, shippingItem.ShippingDocument.ShippingDocumentToFiles?.Count); } } @@ -313,11 +170,11 @@ namespace FruitBankHybrid.Shared.Tests if (shippingItem.IsMeasurable) Assert.IsNotNull(shippingItem.Pallet, $"shippingItem.Pallet == null; shippingItem.PalletId: {shippingItem.PalletId}"); Assert.IsNotNull(shippingItem.ProductDto, $"shippingItem.Product == null; shippingItem.ProductId: {shippingItem.ProductId}"); - Assert.IsTrue(shippingItem.Id == shippingItemId); + Assert.AreEqual(shippingItemId, shippingItem.Id); - Assert.IsTrue(shippingItem.QuantityOnDocument > 0, "QuantityOnDocument == 0"); - Assert.IsTrue(shippingItem.NetWeightOnDocument > 0, "NetWeightOnDocument == 0"); - Assert.IsTrue(shippingItem.GrossWeightOnDocument > 0, "GrossWeightOnDocument == 0"); + Assert.IsGreaterThan(0, shippingItem.QuantityOnDocument, "QuantityOnDocument == 0"); + Assert.IsGreaterThan(0, shippingItem.NetWeightOnDocument, "NetWeightOnDocument == 0"); + Assert.IsGreaterThan(0, shippingItem.GrossWeightOnDocument, "GrossWeightOnDocument == 0"); return shippingItem; } diff --git a/FruitBankHybrid.Shared.Tests/FruitBankEkaerTests.cs b/FruitBankHybrid.Shared.Tests/FruitBankEkaerTests.cs new file mode 100644 index 00000000..5aecaa92 --- /dev/null +++ b/FruitBankHybrid.Shared.Tests/FruitBankEkaerTests.cs @@ -0,0 +1,131 @@ +using AyCode.Services.Nav; +using AyCode.Services.Nav.Ekaer.Models; +using FruitBank.Common; +using FruitBank.Common.Entities; +using FruitBankHybrid.Shared.Services.SignalRs; + +namespace FruitBankHybrid.Shared.Tests +{ + [TestClass] + public sealed class FruitBankEkaerTests + { + private FruitBankSignalRClient _signalRClient = null!; + + [TestInitialize] + public void TestInit() + { + if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!"); + + _signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankEkaerTests)); + } + + #region EkaerHistory / Create + + /// + /// Backfill: minden meglévő szállítólevélre létrehozza az EkaerHistory rekordot (Pending, XML nélkül). + /// A generálás a grid Generate gombjával, kézzel történik. Idempotens: újrafuttatva nem duplikál. + /// + [TestMethod] + public async Task CreateEkaerHistoryForAllShippingDocumentsTest() + { + var shippingDocuments = await _signalRClient.GetShippingDocuments(); + + Assert.IsNotNull(shippingDocuments); + Assert.IsNotEmpty(shippingDocuments); + + foreach (var shippingDocument in shippingDocuments) + { + var ekaerHistory = await _signalRClient.CreateEkaerHistory(shippingDocument.Id, false); + + Console.WriteLine($"doc#{shippingDocument.Id}: EkaerHistory Id: {(ekaerHistory == null ? "NULL VÁLASZ!" : ekaerHistory.Id.ToString())}; Status: {ekaerHistory?.Status}"); + + Assert.IsNotNull(ekaerHistory, $"shippingDocument.Id: {shippingDocument.Id}"); + Assert.IsGreaterThan(0, ekaerHistory.Id, $"shippingDocument.Id: {shippingDocument.Id}"); + Assert.IsFalse(ekaerHistory.IsOutgoing); + Assert.IsNotNull(ekaerHistory.Mappings, $"Mappings null (loadRelations?); shippingDocument.Id: {shippingDocument.Id}"); + Assert.AreEqual(shippingDocument.Id, ekaerHistory.Mappings.Single().ForeignKey, $"A mapping nem a forrás-dokumentumra mutat; shippingDocument.Id: {shippingDocument.Id}"); + } + + // Idempotencia: a második hívás a meglévőt adja vissza, nem duplikál. + var firstDocumentId = shippingDocuments[0].Id; + var again = await _signalRClient.CreateEkaerHistory(firstDocumentId, false); + var histories = await _signalRClient.GetEkaerHistoriesByForeignKey(firstDocumentId); + + Assert.IsNotNull(again); + Assert.IsNotNull(histories); + Assert.AreEqual(1, histories.Count(h => !h.IsOutgoing), $"Duplikált bejövő EkaerHistory; shippingDocumentId: {firstDocumentId}"); + } + + #endregion EkaerHistory / Create + + #region EkaerHistory / Generate + + /// + /// Backfill + teljes Generate-út teszt: minden meglévő szállítólevélre legenerálja az EKÁER XML-t + /// (rekord upsert a szerveren), így a grid valós adatot kap és a Generate gomb útvonala tesztelt. + /// + //[TestMethod] //Kikommentezve: a generálás a grid Generate gombjával, kézzel történik — a teszt később még kelleni fog. + public async Task GenerateEkaerXmlDocumentForAllShippingDocumentsTest() + { + var shippingDocuments = await _signalRClient.GetShippingDocuments(); + + Assert.IsNotNull(shippingDocuments); + Assert.IsNotEmpty(shippingDocuments); + + foreach (var shippingDocument in shippingDocuments) + { + // A generálás mostantól EkaerHistory-Id alapú → előbb a (mapping-elt) rekordot hozzuk létre. + var created = await _signalRClient.CreateEkaerHistory(shippingDocument.Id, false); + Assert.IsNotNull(created, $"CreateEkaerHistory null; shippingDocument.Id: {shippingDocument.Id}"); + var ekaerHistory = await _signalRClient.GenerateEkaerXmlDocument(created.Id); + + // A szerver által visszaadott állapot/hibalista logolása — az assertek ELŐTT, hogy hibánál is látsszon. + Console.WriteLine($"doc#{shippingDocument.Id}: Status: {(ekaerHistory == null ? "NULL VÁLASZ!" : ekaerHistory.Status.ToString())}"); + if (!string.IsNullOrWhiteSpace(ekaerHistory?.ErrorText)) Console.WriteLine($" ErrorText: {ekaerHistory.ErrorText}"); + + Assert.IsNotNull(ekaerHistory, $"shippingDocument.Id: {shippingDocument.Id}"); + Assert.IsFalse(ekaerHistory.IsOutgoing); + Assert.AreEqual(shippingDocument.Id, ekaerHistory.Mappings!.Single().ForeignKey, $"A mapping nem a forrás-dokumentumra mutat; shippingDocument.Id: {shippingDocument.Id}"); + Assert.IsFalse(string.IsNullOrWhiteSpace(ekaerHistory.XmlDoc), $"XmlDoc üres; shippingDocument.Id: {shippingDocument.Id}"); + Assert.IsTrue(ekaerHistory.Status is EkaerStatus.Generated or EkaerStatus.ValidationError, + $"Status: {ekaerHistory.Status}; shippingDocument.Id: {shippingDocument.Id}; ErrorText: {ekaerHistory.ErrorText}"); + + // A grid útvonala: az XmlDoc visszaolvasható tradeCard-dá. + var tradeCard = NavXmlHelper.Deserialize(ekaerHistory.XmlDoc!); + + Assert.IsNotNull(tradeCard); + Console.WriteLine($" items: {tradeCard.Items.Count}"); + } + } + + /// Idempotencia: az újragenerálás NEM duplikál — dokumentumonként egy bejövő rekord marad. + //[TestMethod] //Kikommentezve: a generálás a grid Generate gombjával, kézzel történik — a teszt később még kelleni fog. + public async Task GenerateEkaerXmlDocumentIsIdempotentTest() + { + var shippingDocuments = await _signalRClient.GetShippingDocuments(); + + Assert.IsNotNull(shippingDocuments); + Assert.IsNotEmpty(shippingDocuments); + + var shippingDocumentId = shippingDocuments[0].Id; + + // Generálás EkaerHistory-Id alapján → előbb a rekord (mapping-gel), majd kétszeri generálás ugyanarra az Id-ra. + var created = await _signalRClient.CreateEkaerHistory(shippingDocumentId, false); + Assert.IsNotNull(created); + + var first = await _signalRClient.GenerateEkaerXmlDocument(created.Id); + var second = await _signalRClient.GenerateEkaerXmlDocument(created.Id); + + Assert.IsNotNull(first); + Assert.IsNotNull(second); + Assert.AreEqual(first.Id, second.Id, "Az újragenerálás új rekordot hozott létre frissítés helyett!"); + + var histories = await _signalRClient.GetEkaerHistoriesByForeignKey(shippingDocumentId); + + Assert.IsNotNull(histories); + Assert.AreEqual(1, histories.Count(h => !h.IsOutgoing), $"Duplikált bejövő EkaerHistory; shippingDocumentId: {shippingDocumentId}"); + } + + #endregion EkaerHistory / Generate + } +} diff --git a/FruitBankHybrid.Shared.Tests/FruitBankPartnerTests.cs b/FruitBankHybrid.Shared.Tests/FruitBankPartnerTests.cs new file mode 100644 index 00000000..a3cc4214 --- /dev/null +++ b/FruitBankHybrid.Shared.Tests/FruitBankPartnerTests.cs @@ -0,0 +1,225 @@ +using FruitBank.Common; +using FruitBank.Common.Entities; +using FruitBankHybrid.Shared.Services.SignalRs; + +// ReSharper disable CompareOfFloatsByEqualityOperator + +namespace FruitBankHybrid.Shared.Tests +{ + [TestClass] + public sealed class FruitBankPartnerTests + { + private const int CustomerIdAasdDsserverCom = 6;//aasd@dsserver.com + private const string Fixture = "_test.temp"; + + private FruitBankSignalRClient _signalRClient = null!; + + private static string GetFixtureName(string name) => $"{GetOriginalName(name)}{Fixture}"; + private static string GetOriginalName(string name) => name.Replace(Fixture, string.Empty); + + [TestInitialize] + public void TestInit() + { + if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!"); + + _signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankClientTests)); + } + + #region Partner + + [TestMethod] + public async Task GetPartnersTest() + { + var partners = await _signalRClient.GetPartners(); + + Assert.IsNotNull(partners); + Assert.IsNotEmpty(partners); + } + + //[TestMethod] + //[DataRow(1)] + public async Task GetPartnerByIdTest(int partnerId) + { + var partner = await _signalRClient.GetPartnerById(partnerId); + + Assert.IsNotNull(partner); + Assert.AreEqual(partnerId, partner.Id); + + return partner; + } + + [TestMethod] + [DataRow(2)] + public async Task UpdatePartnerTest(int partnerId) + { + var partner = await GetPartnerByIdTest(partnerId); + + var newName = GetFixtureName(partner.Name); + + partner.Name = newName; + partner = await _signalRClient.UpdatePartner(partner); + + Assert.IsNotNull(partner); + Assert.AreEqual(newName, partner.Name); + + partner.Name = GetOriginalName(partner.Name); + partner = await _signalRClient.UpdatePartner(partner); + + Assert.IsNotNull(partner); + Assert.AreEqual(partnerId, partner.Id); + } + #endregion Partner + + #region CargoPartner + [TestMethod] + public async Task GetCargoPartnersTest() + { + var partners = await _signalRClient.GetCargoPartners(); + + Assert.IsNotNull(partners); + Assert.IsNotEmpty(partners); + } + //[TestMethod] + //[DataRow(1)] + public async Task GetCargoPartnerByIdTest(int cargoPartnerId) + { + var cargoPartner = await _signalRClient.GetCargoPartnerById(cargoPartnerId); + + Assert.IsNotNull(cargoPartner); + Assert.AreEqual(cargoPartnerId, cargoPartner.Id); + + return cargoPartner; + } + + [TestMethod] + [DataRow(1)] + public async Task UpdateCargoPartnerTest(int cargoPartnerId) + { + var cargoPartner = await GetCargoPartnerByIdTest(cargoPartnerId); + + var newName = GetFixtureName(cargoPartner.Name); + + cargoPartner.Name = newName; + cargoPartner = await _signalRClient.UpdateCargoPartner(cargoPartner); + + Assert.IsNotNull(cargoPartner); + Assert.AreEqual(newName, cargoPartner.Name); + + cargoPartner.Name = GetOriginalName(cargoPartner.Name); + cargoPartner = await _signalRClient.UpdateCargoPartner(cargoPartner); + + Assert.IsNotNull(cargoPartner); + Assert.AreEqual(cargoPartnerId, cargoPartner.Id); + } + #endregion CargoPartner + + #region CargoTruck + [TestMethod] + public async Task GetCargoTrucksTest() + { + var cargoTrucks = await _signalRClient.GetCargoTrucks(); + + Assert.IsNotNull(cargoTrucks); + Assert.IsNotEmpty(cargoTrucks); + } + + //[TestMethod] + //[DataRow(1)] + public async Task GetCargoTruckByIdTest(int cargoTruckId) + { + var cargoTruck = await _signalRClient.GetCargoTruckById(cargoTruckId); + + Assert.IsNotNull(cargoTruck); + Assert.AreEqual(cargoTruckId, cargoTruck.Id); + + return cargoTruck; + } + + [TestMethod] + [DataRow(1)] + public async Task GetCargoTrucksByCargoPartnerIdTest(int cargoPartnerId) + { + var cargoTrucks = await _signalRClient.GetCargoTrucksByCargoPartnerId(cargoPartnerId); + + Assert.IsNotNull(cargoTrucks); + Assert.IsNotEmpty(cargoTrucks); + } + + + [TestMethod] + [DataRow(1)] + public async Task UpdateCargoTruckTest(int cargoTruckId) + { + var cargoTruck = await GetCargoTruckByIdTest(cargoTruckId); + + var newLicencePlate = GetFixtureName(cargoTruck.LicencePlate); + + cargoTruck.LicencePlate = newLicencePlate; + cargoTruck = await _signalRClient.UpdateCargoTruck(cargoTruck); + + Assert.IsNotNull(cargoTruck); + Assert.AreEqual(newLicencePlate, cargoTruck.LicencePlate); + + cargoTruck.LicencePlate = GetOriginalName(cargoTruck.LicencePlate); + cargoTruck = await _signalRClient.UpdateCargoTruck(cargoTruck); + + Assert.IsNotNull(cargoTruck); + Assert.AreEqual(cargoTruckId, cargoTruck.Id); + } + #endregion CargoTruck + + #region PartnerDepot + [TestMethod] + public async Task GetPartnerDepotsTest() + { + var partnerDepots = await _signalRClient.GetPartnerDepots(); + + Assert.IsNotNull(partnerDepots); + Assert.IsNotEmpty(partnerDepots); + } + + //[TestMethod] + //[DataRow(1)] + public async Task GetPartnerDepotByIdTest(int partnerDepotId) + { + var partnerDepot = await _signalRClient.GetPartnerDepotById(partnerDepotId); + + Assert.IsNotNull(partnerDepot); + Assert.AreEqual(partnerDepotId, partnerDepot.Id); + + return partnerDepot; + } + + [TestMethod] + [DataRow(1)] + public async Task GetPartnerDepotsByPartnerIdTest(int partnerId) + { + var partnerDepots = await _signalRClient.GetPartnerDepotsByPartnerId(partnerId); + + Assert.IsNotNull(partnerDepots); + Assert.IsNotEmpty(partnerDepots); + } + + [TestMethod] + [DataRow(1)] + public async Task UpdatePartnerDepotTest(int partnerDepotId) + { + var partnerDepot = await GetPartnerDepotByIdTest(partnerDepotId); + + var newName = GetFixtureName(partnerDepot.Name); + + partnerDepot.Name = newName; + partnerDepot = await _signalRClient.UpdatePartnerDepot(partnerDepot); + + Assert.IsNotNull(partnerDepot); + Assert.AreEqual(newName, partnerDepot.Name); + + partnerDepot.Name = GetOriginalName(partnerDepot.Name); + partnerDepot = await _signalRClient.UpdatePartnerDepot(partnerDepot); + + Assert.IsNotNull(partnerDepot); + Assert.AreEqual(partnerDepotId, partnerDepot.Id); + } + #endregion PartnerDepot + } +} diff --git a/FruitBankHybrid.Shared.Tests/SignalRTagsTests.cs b/FruitBankHybrid.Shared.Tests/SignalRTagsTests.cs new file mode 100644 index 00000000..42cfef12 --- /dev/null +++ b/FruitBankHybrid.Shared.Tests/SignalRTagsTests.cs @@ -0,0 +1,38 @@ +using System.Reflection; +using FruitBank.Common.SignalRs; + +namespace FruitBankHybrid.Shared.Tests +{ + [TestClass] + public sealed class SignalRTagsTests + { + /// + /// A értékei a drót-protokoll azonosítói: két azonos értékű + /// konstans (jellemzően copy-paste után) némán másik endpointra irányítaná a hívást. + /// A kézi számkiosztás szándékos (stabil szerződés), ez a teszt csak alá feszít egy hálót: + /// kibukik a duplikátumon, és megnevezi a vétkes konstansokat. + /// + /// + /// Csak a -ben DEKLARÁLT konstansokat vizsgálja (DeclaredOnly) — + /// ide ír a fejlesztő kézzel. A framework-ős AcSignalRTags-szal való átfedés + /// ellenőrzéséhez BindingFlags.FlattenHierarchy kellene. + /// + [TestMethod] + public void SignalRTags_HasNoDuplicateValues() + { + var tags = typeof(SignalRTags) + .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Where(f => f.IsLiteral && f.FieldType == typeof(int)) + .Select(f => (f.Name, Value: (int)f.GetRawConstantValue()!)) + .ToList(); + + var duplicates = tags + .GroupBy(t => t.Value) + .Where(g => g.Count() > 1) + .Select(g => $" {g.Key} = {string.Join(", ", g.Select(t => t.Name))}") + .ToList(); + + Assert.AreEqual(0, duplicates.Count, $"Több SignalRTag ugyanazt az értéket kapta:{Environment.NewLine}{string.Join(Environment.NewLine, duplicates)}"); + } + } +} diff --git a/FruitBankHybrid.Shared.Tests/ToonTests.cs b/FruitBankHybrid.Shared.Tests/ToonTests.cs index fa8609e4..7e0223aa 100644 --- a/FruitBankHybrid.Shared.Tests/ToonTests.cs +++ b/FruitBankHybrid.Shared.Tests/ToonTests.cs @@ -88,6 +88,7 @@ public class FullProcessModel public List Orders { get; set; } public List PreOrders { get; set; } public List StockTakings { get; set; } + public List EkaerHistories { get; set; } } [TestClass] diff --git a/FruitBankHybrid.Shared/Components/GridShippingDocument.razor b/FruitBankHybrid.Shared/Components/GridShippingDocument.razor index c8cdbd3d..c8e55b8e 100644 --- a/FruitBankHybrid.Shared/Components/GridShippingDocument.razor +++ b/FruitBankHybrid.Shared/Components/GridShippingDocument.razor @@ -1,4 +1,4 @@ -@using AyCode.Blazor.Components.Components.Grids +@using AyCode.Blazor.Components.Components.Grids @using AyCode.Utils.Extensions @using FruitBank.Common.Dtos @using FruitBank.Common.Entities @@ -26,24 +26,65 @@ - - - - - - - - - + + @{ + var sdPd = (ShippingDocument)context.DataItem; + @(PartnersDictById.GetValueOrDefault(sdPd.PartnerId)?.Name) + } + + + @{ + var sdPe = (ShippingDocument)context.EditModel; + } + + + + + + + + - + + + @{ + var sdD = (ShippingDocument)context.DataItem; + var depotD = PartnersDictById.GetValueOrDefault(sdD.PartnerId)?.PartnerDepots?.FirstOrDefault(d => d.Id == sdD.PartnerDepotId); + +
@depotD?.Name
+ } +
+ + @{ + var sdE = (ShippingDocument)context.EditModel; + var depotsE = PartnersDictById.GetValueOrDefault(sdE.PartnerId)?.PartnerDepots ?? new List(); + } + + + + + + + +
+ _logger = null!; private int _activeTabIndex; - protected override async Task OnInitializedAsync() + // Partner-lookup ID szerint a cella-templatekhez (a ReloadDataFromDb tölti) — renderenkénti lineáris keresés helyett. + private Dictionary PartnersDictById = new(); + + protected override async Task OnInitializedAsync() { _logger = new LoggerClient(LogWriters.ToArray()); @@ -223,9 +267,13 @@ { if (Shippings == null && ParentDataItem is Shipping shippingParent) Shippings = [shippingParent]; if (Partners == null && ParentDataItem is Partner partnerParent) Partners = [partnerParent]; - return; } + // Partner-lookup ID szerint a cella-templatekhez (O(1) a renderenkénti FirstOrDefault helyett), a Partners véglegesülése után. + PartnersDictById = Partners?.ToDictionary(p => p.Id) ?? new(); + + if (!IsMasterGrid) return; + if (Grid == null) return; using (await ObjectLock.GetSemaphore().UseWaitAsync()) @@ -269,6 +317,16 @@ EditItemsEnabled = true; } + // Cascade: a Partner-combo ValueChanged-je. Beírjuk az új partnert; ha a partnernek PONTOSAN EGY telephelye van, + // arra auto-állunk, különben nullázzuk (a régi nem az új partneré); majd újrarenderelünk → a Telephely-combo frissül. + void OnPartnerChanged(ShippingDocument sd, int partnerId) + { + sd.PartnerId = partnerId; + var depots = PartnersDictById.GetValueOrDefault(partnerId)?.PartnerDepots; + sd.PartnerDepotId = depots?.Count == 1 ? depots[0].Id : null; + StateHasChanged(); + } + protected async Task OnActiveTabChanged(int activeTabIndex) { _activeTabIndex = activeTabIndex; diff --git a/FruitBankHybrid.Shared/Components/Grids/Cargos/GridCargoPartner.razor b/FruitBankHybrid.Shared/Components/Grids/Cargos/GridCargoPartner.razor index 07ea7f23..626bcf65 100644 --- a/FruitBankHybrid.Shared/Components/Grids/Cargos/GridCargoPartner.razor +++ b/FruitBankHybrid.Shared/Components/Grids/Cargos/GridCargoPartner.razor @@ -38,8 +38,8 @@ - - + +
diff --git a/FruitBankHybrid.Shared/Components/Grids/Cargos/GridCargoTruck.razor b/FruitBankHybrid.Shared/Components/Grids/Cargos/GridCargoTruck.razor index 3663e202..61144166 100644 --- a/FruitBankHybrid.Shared/Components/Grids/Cargos/GridCargoTruck.razor +++ b/FruitBankHybrid.Shared/Components/Grids/Cargos/GridCargoTruck.razor @@ -32,8 +32,8 @@ - - + + diff --git a/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerDetail.razor b/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerDetail.razor new file mode 100644 index 00000000..072627fa --- /dev/null +++ b/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerDetail.razor @@ -0,0 +1,119 @@ +@using AyCode.Blazor.Components.Components.Grids +@using AyCode.Core.Helpers +@using AyCode.Core.Interfaces +@using AyCode.Core.Loggers +@using AyCode.Services.Nav.Ekaer.Models +@using FruitBank.Common.Entities +@using FruitBankHybrid.Shared.Services.Loggers +@using FruitBankHybrid.Shared.Services.SignalRs + +@inject IEnumerable LogWriters +@inject FruitBankSignalRClient FruitBankSignalRClient + +@if (TradeCard != null) +{ + @* Fejléc: csak a user-nek fontos, a mapper által ténylegesen töltött mezők. *@ +
+ Irány:@TradeCard.TradeType + Fuvarozó:@TradeCard.CarrierText + + Eladó:@FormatParty(TradeCard.SellerName, TradeCard.SellerVatNumber, TradeCard.SellerAddress) + Címzett:@FormatParty(TradeCard.DestinationName, TradeCard.DestinationVatNumber, TradeCard.DestinationAddress) + + Felrakodás:@FormatLocation(TradeCard.LoadLocation) + Lerakodás:@FormatLocation(TradeCard.UnloadLocation) + + Vontató:@TradeCard.Vehicle?.PlateNumber + Pótkocsi:@TradeCard.Vehicle2?.PlateNumber +
+} + +@if (!string.IsNullOrWhiteSpace(ErrorText)) +{ + @* A generálás/küldés üzenetei SORONKÉNT, a saját súlyosságukkal színezve: error → piros, warning → arany/sárga. *@ +
+ @foreach (var line in ErrorMessageLines) + { +
@line.Text
+ } +
+} + + + + + + + + + + + + + + + + +@code { + [Parameter] public TradeCardType? TradeCard { get; set; } + [Parameter] public IId? ParentDataItem { get; set; } + + public GridEkaerDetailBase Grid { get; set; } + + public bool IsMasterGrid => ParentDataItem == null; + string GridCss => !IsMasterGrid ? "hide-toolbar" : string.Empty; + + private string? ErrorText => (ParentDataItem as EkaerHistory)?.ErrorText; + + // Soronkénti üzenet + súlyosság az [Error]/[Warning] prefixből (a service így fűzi). Prefix nélküli (pl. config-hiba) → error. + private IEnumerable<(bool IsError, string Text)> ErrorMessageLines + { + get + { + if (string.IsNullOrWhiteSpace(ErrorText)) yield break; + + foreach (var raw in ErrorText.Split('\n')) + { + var line = raw.Trim(); + if (line.Length == 0) continue; + + if (line.StartsWith("[Warning]")) yield return (false, line["[Warning]".Length..].TrimStart()); + else if (line.StartsWith("[Error]")) yield return (true, line["[Error]".Length..].TrimStart()); + else yield return (true, line); + } + } + } + + private AcObservableCollection TradeCardItems = []; + + private LoggerClient _logger; + + protected override void OnInitialized() + { + _logger = new LoggerClient(LogWriters.ToArray()); + } + + protected override void OnParametersSet() + { + TradeCardItems = TradeCard?.Items is { } items ? new AcObservableCollection(items) : []; + } + + private static string FormatParty(string? name, string? vatNumber, string? address) + { + var party = string.IsNullOrWhiteSpace(vatNumber) ? name : $"{name} ({vatNumber})"; + return string.IsNullOrWhiteSpace(address) ? party ?? string.Empty : $"{party} — {address}"; + } + + private static string FormatLocation(LocationType? location) + { + if (location == null) return string.Empty; + return string.Join(" ", new[] { location.ZipCode, location.City, location.Street }.Where(p => !string.IsNullOrWhiteSpace(p))); + } +} diff --git a/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerDetailBase.cs b/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerDetailBase.cs new file mode 100644 index 00000000..44171b4f --- /dev/null +++ b/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerDetailBase.cs @@ -0,0 +1,19 @@ +using AyCode.Services.Nav.Ekaer.Models; +using DevExpress.Blazor; +using FruitBankHybrid.Shared.Pages; +using Microsoft.AspNetCore.Components; + +namespace FruitBankHybrid.Shared.Components.Grids.Ekaers; + +/// +/// Readonly tétel-grid az EKÁER detail row-hoz: a generált tradeCard tételeit () +/// mutatja, az EkaerHistory.XmlDoc-ból lokálisan deszerializálva. SZÁNDÉKOSAN nincs Get/Add/Update/Remove +/// tag — tag nélkül a grid readonly, a DataSource pedig lokális (nem SignalR-ből töltött). +/// A az IId<int>-et explicit partial-lal teljesíti (Id = ItemExternalId-ból számítva). +/// +public class GridEkaerDetailBase : FruitBankGridBase, IGrid +{ + public GridEkaerDetailBase() : base() + { + } +} diff --git a/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerHistory.razor b/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerHistory.razor new file mode 100644 index 00000000..e3f7e648 --- /dev/null +++ b/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerHistory.razor @@ -0,0 +1,313 @@ +@using AyCode.Blazor.Components.Components.Grids +@using AyCode.Core.Helpers +@using AyCode.Core.Interfaces +@using AyCode.Core.Loggers +@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 + + + + + + + + @* A lefedett forrás-rekordok (ShippingDocument/Order Id-k) a betöltött Mappings-ből összefűzve — + egyből látszik, ha több dokumentum van egy EKÁER-deklaráció alá vonva. *@ + + @{ + var row = (EkaerHistory)context.DataItem; + } + @if (row.Mappings is { Count: > 0 } maps) + { + var noun = row.IsOutgoing ? "rendelés" : "szállítólevél"; + @string.Join(", ", maps.Select(m => $"#{m.ForeignKey}")) + } + else + { + + } + + + + + + @* A kézi NAV-beadás fázisában a Status / EKÁER szám / SentDate kézzel szerkeszthető. *@ + + + + @* Audit: a value-számításhoz alkalmazott árfolyam (HUF feladónál 1). ReadOnly — generáláskor töltődik. *@ + + @* Üzenet-oszlop: warningot ÉS errort is mutat. Ikon a súlyosság szerint: ⛔ blokkoló hiba, ⚠️ warning (pótlandó). *@ + + + @if (!string.IsNullOrWhiteSpace(context.DisplayText)) + { + var isError = ((EkaerHistory)context.DataItem).Status.IsError(); + + @context.DisplayText + + } + + + + + + + + @{ + var row = (EkaerHistory)context.DataItem; + + + } + + + + + + @{ + var ekaerHistory = (EkaerHistory)context.DataItem; + TradeCardType? tradeCard = null; + + if (!string.IsNullOrWhiteSpace(ekaerHistory.XmlDoc)) + { + try + { + tradeCard = NavXmlHelper.Deserialize(ekaerHistory.XmlDoc); + } + catch (Exception ex) + { + _logger.Error($"EkaerHistory XmlDoc deserialize error; id: {ekaerHistory.Id}", ex); + } + } + + + } + + + @if (IsMasterGrid) + { + @* EKÁER-rekord a toolbar "sorok létrehozása" gombbal készül (new/delete tiltva — nincs automata + érzékelés, a létrehozás explicit és idempotens). Az Edit a kézi NAV-beadás fázisában + engedélyezett: Status / EKÁER szám / SentDate kézi rögzítéséhez. *@ + + + @* 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. *@ + + + + + + + + } + + + + + +@code { + [Inject] public required DatabaseClient Database { get; set; } + + [Parameter] public AcObservableCollection? EkaerHistories { get; set; } + + const string ExportFileName = "ExportResult"; + string GridSearchText = ""; + bool EditItemsEnabled { get; set; } + int FocusedRowVisibleIndex { get; set; } + public GridEkaerHistoryBase Grid { get; set; } + string GridCss => !IsMasterGrid ? "hide-toolbar" : string.Empty; + + [Parameter] public IId? ParentDataItem { get; set; } + + /// Szerver-oldali szűrő (a tabok adják): ToSubmit / Sent / NeedsCompletion. Default All = minden. + [Parameter] public EkaerHistoryFilter Filter { get; set; } = EkaerHistoryFilter.All; + + /// A szülő (oldal) értesítése, ha egy művelet státuszt válthatott (generálás / kézi mentés / jövőbeni beküldés) — + /// 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; + + protected override async Task OnInitializedAsync() + { + _logger = new LoggerClient(LogWriters.ToArray()); + await ReloadDataFromDb(false); + } + + private async Task ReloadDataFromDb(bool forceReload = false) + { + if (!IsMasterGrid) return; + + if (Grid == null) return; + + using (await ObjectLock.GetSemaphore().UseWaitAsync()) + if (forceReload) await Grid.ReloadDataSourceAsync(); + + if (forceReload) Grid.Reload(); + } + + async Task Grid_FocusedRowChanged(GridFocusedRowChangedEventArgs args) + { + if (Grid == null) return; + + if (Grid.IsEditing() && !Grid.IsEditingNewRow()) + { + await Grid.SaveChangesAsync(); + await NotifyDataChanged(); // kézi szerkesztés (pl. Status → SentWithMissingData) → fül-szám frissítése + } + + FocusedRowVisibleIndex = args.VisibleIndex; + EditItemsEnabled = true; + } + + private readonly HashSet _generatingIds = []; + + // Elküldött (NAV-nál lévő) bejelentést nem generálunk újra némán; minden más állapot újragenerálható. + private bool CanGenerate(EkaerHistory ekaerHistory) + => !ekaerHistory.Status.IsSent() && !_generatingIds.Contains(ekaerHistory.Id); + + // Csak BLOKKOLÓ hiba (ValidationError) esetén NEM másolható; a warning (pl. hiányzó rendszám) küldhető → másolható. + private static bool CanCopy(EkaerHistory ekaerHistory) + => ekaerHistory.Status.IsSubmittable() && !string.IsNullOrWhiteSpace(ekaerHistory.XmlDoc); + + // A szülő-oldal (fül-számlálók) értesítése egy státuszt érintő művelet után. Üres delegáltnál no-op. + private Task NotifyDataChanged() => OnDataChanged.HasDelegate ? OnDataChanged.InvokeAsync() : Task.CompletedTask; + + private async Task OnGenerateClick(EkaerHistory ekaerHistory) + { + if (!_generatingIds.Add(ekaerHistory.Id)) return; + + try + { + var updated = await FruitBankSignalRClient.GenerateEkaerXmlDocument(ekaerHistory.Id); + + if (updated == null) + { + _logger.Error($"GenerateEkaerXmlDocument null választ adott; EkaerHistory #{ekaerHistory.Id}"); + return; + } + + // A sor frissítése helyben — a grid ugyanazt a példányt mutatja. + ekaerHistory.Status = updated.Status; + ekaerHistory.XmlDoc = updated.XmlDoc; + ekaerHistory.ConversionRate = updated.ConversionRate; + ekaerHistory.ErrorText = updated.ErrorText; + ekaerHistory.EkaerNumber = updated.EkaerNumber; + ekaerHistory.SentDate = updated.SentDate; + ekaerHistory.Modified = updated.Modified; + } + catch (Exception ex) + { + _logger.Error($"GenerateEkaerXmlDocument hiba; EkaerHistory #{ekaerHistory.Id}", ex); + } + finally + { + _generatingIds.Remove(ekaerHistory.Id); + Grid?.Reload(); + await NotifyDataChanged(); + } + } + + DateTime CreateFromDate { get; set; } = DateTime.Today.AddDays(-3); + private bool _creatingMissing; + + private async Task OnCreateMissingClick() + { + if (_creatingMissing) return; + _creatingMissing = true; + await OnBusyChanged.InvokeAsync(true); // Loading panel BE — a művelet lassú lehet (kimenő ág) + + EkaerCreateResult? result = null; + try + { + 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}"); + + 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) + { + _logger.Error($"CreateMissingEkaerHistories hiba; fromDate: {CreateFromDate:yyyy.MM.dd}", ex); + } + 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) + { + if (!CanCopy(ekaerHistory)) return; + + try + { + await JS.InvokeVoidAsync("navigator.clipboard.writeText", ekaerHistory.XmlDoc); + } + catch (Exception ex) + { + _logger.Error($"XmlDoc vágólapra másolása sikertelen; EkaerHistory.Id: {ekaerHistory.Id}", ex); + } + } +} diff --git a/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerHistoryBase.cs b/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerHistoryBase.cs new file mode 100644 index 00000000..c4cbe0f0 --- /dev/null +++ b/FruitBankHybrid.Shared/Components/Grids/Ekaers/GridEkaerHistoryBase.cs @@ -0,0 +1,75 @@ +using AyCode.Utils.Extensions; +using DevExpress.Blazor; +using FruitBank.Common.Entities; +using FruitBank.Common.SignalRs; +using FruitBankHybrid.Shared.Pages; +using Microsoft.AspNetCore.Components; + +namespace FruitBankHybrid.Shared.Components.Grids.Ekaers; + +public class GridEkaerHistoryBase : FruitBankGridBase, IGrid +{ + private bool _isFirstInitializeParameterCore; + private bool _isFirstInitializeParameters; + + /// Szerver-oldali szűrő a master-grid lekérdezéséhez (a tabok adják át). All = minden. + [Parameter] public EkaerHistoryFilter Filter { get; set; } = EkaerHistoryFilter.All; + + public GridEkaerHistoryBase() : base() + { + //GetAllMessageTag = SignalRTags.GetEkaerHistories; + AddMessageTag = SignalRTags.AddEkaerHistory; + UpdateMessageTag = SignalRTags.UpdateEkaerHistory; + + //RemoveMessageTag = SignalRTags.; + } + + protected override async Task OnInitializedAsync() + { + if (GetAllMessageTag > 0) return; + + if (IsMasterGrid) + { + GetAllMessageTag = SignalRTags.GetEkaerHistories; + ContextIds = [Filter]; // szerver-oldali szűrő a tabokhoz (All = minden) — a GetEkaerHistories(filter) param-ja + } + else + { + if (ContextIds == null || ContextIds.Length == 0) ContextIds = [ParentDataItem!.Id]; + + // Detail-mód: a forrás-rekord (Shipping/Order/ShippingDocument) EKÁER-deklarációi a mappingen át. + // Megj.: az EkaerHistory már NEM hordoz per-szülő mezőt (a kapcsolat az EkaerHistoryMapping junctionben él), + // ezért a kliens-oldali KeyFieldNameToParentId itt nem alkalmazható — a szerver-oldali szűrés + // (GetEkaerHistoriesByForeignKey → mapping) adja a szülőhöz tartozó sorokat. (Jelenleg nincs detail-használat.) + GetAllMessageTag = SignalRTags.GetEkaerHistoriesByForeignKey; + } + + await base.OnInitializedAsync(); + } + + protected override void OnParametersSet() + { + base.OnParametersSet(); + + if (!_isFirstInitializeParameters) + { + _isFirstInitializeParameters = false; + } + } + + protected override async Task SetParametersAsyncCore(ParameterView parameters) + { + await base.SetParametersAsyncCore(parameters); + + if (!_isFirstInitializeParameterCore) + { + //ShowFilterRow = true; + //ShowGroupPanel = true; + //AllowSort = false; + + //etc... + + _isFirstInitializeParameterCore = false; + } + } +} diff --git a/FruitBankHybrid.Shared/Components/Grids/Partners/GridPartner.razor b/FruitBankHybrid.Shared/Components/Grids/Partners/GridPartner.razor index 0bc66eb8..c4e9a301 100644 --- a/FruitBankHybrid.Shared/Components/Grids/Partners/GridPartner.razor +++ b/FruitBankHybrid.Shared/Components/Grids/Partners/GridPartner.razor @@ -29,7 +29,7 @@ - + @@ -38,14 +38,15 @@ - - + + @if (IsMasterGrid) { var partner = ((Partner)context.DataItem); + var partnerDepots = partner?.PartnerDepots ?? []; var shippingDocuments = partner?.ShippingDocuments ?? []; @@ -62,6 +63,13 @@ } + + + @{ + var observablePartnerDepots = new AcObservableCollection(partnerDepots); + + } + } diff --git a/FruitBankHybrid.Shared/Components/Grids/Partners/GridPartnerDepot.razor b/FruitBankHybrid.Shared/Components/Grids/Partners/GridPartnerDepot.razor new file mode 100644 index 00000000..0862fd24 --- /dev/null +++ b/FruitBankHybrid.Shared/Components/Grids/Partners/GridPartnerDepot.razor @@ -0,0 +1,124 @@ +@using System.Collections.ObjectModel +@using AyCode.Blazor.Components.Components.Grids +@using AyCode.Core.Helpers +@using AyCode.Core.Interfaces +@using AyCode.Core.Loggers +@using AyCode.Utils.Extensions +@using FruitBank.Common.Dtos +@using FruitBank.Common.Entities +@using FruitBankHybrid.Shared.Components.Grids.Shippings +@using FruitBankHybrid.Shared.Databases +@using FruitBankHybrid.Shared.Services.Loggers +@using FruitBankHybrid.Shared.Services.SignalRs + +@inject IEnumerable LogWriters +@inject FruitBankSignalRClient FruitBankSignalRClient + + + + + + + + + + + + + + + + + + + @* + @if (IsMasterGrid) + { + var partnerDepot = ((PartnerDepot)context.DataItem); + + + + @{ + var observableShippingDocuments = new AcObservableCollection(shippingDocuments); + + } + + + + @{ + var observableShippingItems = new AcObservableCollection(shippingDocuments.SelectMany(sd => sd.ShippingItems ?? [])); + + } + + + } + + *@ + @if (IsMasterGrid) + { + + } + + + + + +@code { + //[Inject] public required ObjectLock ObjectLock { get; set; } + [Inject] public required DatabaseClient Database { get; set; } + + [Parameter] public AcObservableCollection? PartnerDepots { get; set; } + + const string ExportFileName = "ExportResult"; + string GridSearchText = ""; + bool EditItemsEnabled { get; set; } + int FocusedRowVisibleIndex { get; set; } + public GridPartnerDepotBase Grid { get; set; } + string GridCss => !IsMasterGrid ? "hide-toolbar" : string.Empty; + + [Parameter] public IId? ParentDataItem { get; set; } + + public bool IsMasterGrid => ParentDataItem == null; + public bool ParentDataItemIsPartner => (ParentDataItem is Partner); + + private int _activeTabIndex; + private LoggerClient _logger; + + protected override async Task OnInitializedAsync() + { + _logger = new LoggerClient(LogWriters.ToArray()); + await ReloadDataFromDb(false); + } + + private async Task ReloadDataFromDb(bool forceReload = false) + { + if (!IsMasterGrid) return; + + if (Grid == null) return; + + using (await ObjectLock.GetSemaphore().UseWaitAsync()) + if (forceReload) await Grid.ReloadDataSourceAsync(); + + if (forceReload) Grid.Reload(); + } + + async Task Grid_FocusedRowChanged(GridFocusedRowChangedEventArgs args) + { + if (Grid == null) return; + + if (Grid.IsEditing() && !Grid.IsEditingNewRow()) + await Grid.SaveChangesAsync(); + + FocusedRowVisibleIndex = args.VisibleIndex; + EditItemsEnabled = true; + } +} + diff --git a/FruitBankHybrid.Shared/Components/Grids/Partners/GridPartnerDepotBase.cs b/FruitBankHybrid.Shared/Components/Grids/Partners/GridPartnerDepotBase.cs new file mode 100644 index 00000000..c62b4e50 --- /dev/null +++ b/FruitBankHybrid.Shared/Components/Grids/Partners/GridPartnerDepotBase.cs @@ -0,0 +1,85 @@ +using AyCode.Core.Interfaces; +using AyCode.Utils.Extensions; +using DevExpress.Blazor; +using FruitBank.Common.Entities; +using FruitBank.Common.Interfaces; +using FruitBank.Common.SignalRs; +using FruitBankHybrid.Shared.Pages; +using Microsoft.AspNetCore.Components; + +namespace FruitBankHybrid.Shared.Components.Grids.Partners; + +public class GridPartnerDepotBase: FruitBankGridBase, IGrid +{ + private bool _isFirstInitializeParameterCore; + private bool _isFirstInitializeParameters; + + public GridPartnerDepotBase() : base() + { + //GetAllMessageTag = SignalRTags.GetPartnerDepots; + AddMessageTag = SignalRTags.AddPartnerDepot; + UpdateMessageTag = SignalRTags.UpdatePartnerDepot; + + //RemoveMessageTag = SignalRTags.; + } + + protected override async Task OnInitializedAsync() + { + if (GetAllMessageTag > 0) return; + + if (IsMasterGrid) GetAllMessageTag = SignalRTags.GetPartnerDepots; + else + { + if (ContextIds == null || ContextIds.Length == 0) ContextIds = [ParentDataItem!.Id]; + + switch (ParentDataItem) + { + case IPartner: + GetAllMessageTag = SignalRTags.GetPartnerDepotsByPartnerId; + if (KeyFieldNameToParentId.IsNullOrWhiteSpace()) KeyFieldNameToParentId = nameof(PartnerDepot.PartnerId); + + break; + } + } + + await base.OnInitializedAsync(); + } + + protected override void OnParametersSet() + { + base.OnParametersSet(); + + if (!_isFirstInitializeParameters) + { + //if (!IsMasterGrid && (ContextIds == null || ContextIds.Length == 0)) + //{ + // ContextIds = [ParentDataItem!.Id]; + // GetAllMessageTag = SignalRTags.GetShippingItemsByDocumentId; + //} + + _isFirstInitializeParameters = false; + } + } + + protected override async Task SetParametersAsyncCore(ParameterView parameters) + { + await base.SetParametersAsyncCore(parameters); + + if (!_isFirstInitializeParameterCore) + { + //if (!IsMasterGrid && (ContextIds == null || ContextIds.Length == 0)) + //{ + // ContextIds = [ParentDataItem!.Id]; + // GetAllMessageTag = SignalRTags.GetShippingItemsByDocumentId; + //} + + //ShowFilterRow = true; + //ShowGroupPanel = true; + //AllowSort = false; + + //etc... + + _isFirstInitializeParameterCore = false; + } + } +} \ No newline at end of file diff --git a/FruitBankHybrid.Shared/Components/Grids/Shippings/GridShipping.razor b/FruitBankHybrid.Shared/Components/Grids/Shippings/GridShipping.razor index c668cd3a..5a9a2378 100644 --- a/FruitBankHybrid.Shared/Components/Grids/Shippings/GridShipping.razor +++ b/FruitBankHybrid.Shared/Components/Grids/Shippings/GridShipping.razor @@ -86,8 +86,8 @@ - - + + diff --git a/FruitBankHybrid.Shared/Databases/DatabaseClient.cs b/FruitBankHybrid.Shared/Databases/DatabaseClient.cs index 7c7e3531..7968b9e8 100644 --- a/FruitBankHybrid.Shared/Databases/DatabaseClient.cs +++ b/FruitBankHybrid.Shared/Databases/DatabaseClient.cs @@ -70,6 +70,8 @@ public class ProductDtoTable(FruitBankSignalRClient fruitBankSignalRClient) : Ac //Clear(); this.Replace(items); + //this.Replace(items.Cast()); + //foreach (var productDto in items) //{ // this.UpdateCollection(productDto, false); diff --git a/FruitBankHybrid.Shared/FruitBankHybrid.Shared.csproj b/FruitBankHybrid.Shared/FruitBankHybrid.Shared.csproj index afd1b00e..2e1e8fe4 100644 --- a/FruitBankHybrid.Shared/FruitBankHybrid.Shared.csproj +++ b/FruitBankHybrid.Shared/FruitBankHybrid.Shared.csproj @@ -43,9 +43,6 @@ ..\..\..\..\Aycode\Source\AyCode.Core\AyCode.Services.Server\bin\FruitBank\$(Configuration)\net9.0\AyCode.Services.dll - - ..\..\..\..\Aycode\Source\AyCode.Core\AyCode.Services.Server\bin\FruitBank\$(Configuration)\net9.0\AyCode.Services.Server.dll - ..\..\..\..\Aycode\Source\AyCode.Core\AyCode.Services.Server\bin\FruitBank\$(Configuration)\net9.0\AyCode.Utils.dll diff --git a/FruitBankHybrid.Shared/Layout/NavMenu.razor b/FruitBankHybrid.Shared/Layout/NavMenu.razor index 03b90813..ca3286ad 100644 --- a/FruitBankHybrid.Shared/Layout/NavMenu.razor +++ b/FruitBankHybrid.Shared/Layout/NavMenu.razor @@ -63,6 +63,12 @@ Szállítmányok + + } } else diff --git a/FruitBankHybrid.Shared/Layout/NavMenu.razor.css b/FruitBankHybrid.Shared/Layout/NavMenu.razor.css index e1614b30..2987b88d 100644 --- a/FruitBankHybrid.Shared/Layout/NavMenu.razor.css +++ b/FruitBankHybrid.Shared/Layout/NavMenu.razor.css @@ -59,6 +59,10 @@ --icon-mask-image: var(--icon-login-mask-image); } +::deep .ekaer-icon { + --icon-mask-image: var(--icon-ekaer-mask-image); +} + .nav-item { font-size: 0.9rem; padding-bottom: 0.5rem; diff --git a/FruitBankHybrid.Shared/Pages/Ekaer.razor b/FruitBankHybrid.Shared/Pages/Ekaer.razor new file mode 100644 index 00000000..cf949742 --- /dev/null +++ b/FruitBankHybrid.Shared/Pages/Ekaer.razor @@ -0,0 +1,30 @@ +@page "/Ekaer" +@using FruitBank.Common.Entities +@using FruitBankHybrid.Shared.Components +@using FruitBankHybrid.Shared.Components.Grids.Ekaers +@using FruitBankHybrid.Shared.Databases +

EKÁER - Adminisztrátor

+ + + +
+ + + + + + + + + + + + + + + +
diff --git a/FruitBankHybrid.Shared/Pages/Ekaer.razor.cs b/FruitBankHybrid.Shared/Pages/Ekaer.razor.cs new file mode 100644 index 00000000..eb3c769b --- /dev/null +++ b/FruitBankHybrid.Shared/Pages/Ekaer.razor.cs @@ -0,0 +1,70 @@ +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; +using Microsoft.AspNetCore.Components; + +namespace FruitBankHybrid.Shared.Pages; + +public partial class Ekaer : ComponentBase +{ + [Inject] public required IEnumerable LogWriters { get; set; } + [Inject] public required NavigationManager NavManager { get; set; } + [Inject] public required LoggedInModel LoggedInModel { get; set; } + [Inject] public required FruitBankSignalRClient FruitBankSignalRClient { get; set; } + + private GridEkaerHistory gridEkaerHistoryPending; + private GridEkaerHistory gridEkaerHistorySent; + private GridEkaerHistory gridEkaerHistoryNeedsCompletion; + + private ILogger _logger = null!; + public int ActiveTabIndex; + + /// Pótlásra váró (SentWithMissingData) rekordok száma a fül feliratához — hogy ránézésre látsszon, ha van pótolnivaló. + private int _needsCompletionCount; + public string NeedsCompletionTabText => _needsCompletionCount > 0 ? $"Pótlásra váró ({_needsCompletionCount})" : "Pótlásra váró"; + + protected override async Task OnInitializedAsync() + { + if (!LoggedInModel.IsRevisor) NavManager.NavigateTo("/Login"); + + _logger = new LoggerClient(LogWriters.ToArray()); + _logger.Info("OnInitializedAsync"); + + await base.OnInitializedAsync(); + await RefreshNeedsCompletionCountAsync(); + } + + protected async Task OnActiveTabChanged(int activeTabIndex) + { + ActiveTabIndex = activeTabIndex; + // Fülváltáskor frissítjük a számlálót — a gridben végzett művelet (generálás/státusz-állítás) megváltoztathatta. + 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() + { + try + { + _needsCompletionCount = await FruitBankSignalRClient.GetEkaerHistoryCount(EkaerHistoryFilter.NeedsCompletion); + //_needsCompletionCount = await FruitBankSignalRClient.GetEkaerHistoryCount(EkaerHistoryFilter.ToSubmit); + StateHasChanged(); + } + catch (Exception ex) + { + _logger.Error($"GetEkaerHistoryCount hiba: {ex.Message}"); + } + } +} diff --git a/FruitBankHybrid.Shared/Pages/OrdersAdmin.razor.cs b/FruitBankHybrid.Shared/Pages/OrdersAdmin.razor.cs index ecabc3c2..5c03db52 100644 --- a/FruitBankHybrid.Shared/Pages/OrdersAdmin.razor.cs +++ b/FruitBankHybrid.Shared/Pages/OrdersAdmin.razor.cs @@ -1,6 +1,5 @@ using AyCode.Core.Interfaces; using AyCode.Core.Loggers; -using AyCode.Services.Server.SignalRs; using AyCode.Services.SignalRs; using AyCode.Utils.Extensions; using DevExpress.Blazor; diff --git a/FruitBankHybrid.Shared/Services/SignalRs/FruitBankSignalRClient.cs b/FruitBankHybrid.Shared/Services/SignalRs/FruitBankSignalRClient.cs index 451d3e98..c18aafc8 100644 --- a/FruitBankHybrid.Shared/Services/SignalRs/FruitBankSignalRClient.cs +++ b/FruitBankHybrid.Shared/Services/SignalRs/FruitBankSignalRClient.cs @@ -1,7 +1,6 @@ using AyCode.Core.Extensions; using AyCode.Core.Helpers; using AyCode.Core.Loggers; -using AyCode.Services.Server.SignalRs; using AyCode.Services.SignalRs; using DevExpress.Utils.About; using FruitBank.Common; @@ -66,6 +65,26 @@ namespace FruitBankHybrid.Shared.Services.SignalRs public Task UpdatePartner(Partner partner) => PostDataAsync(SignalRTags.UpdatePartner, partner); #endregion Partner + #region PartnerDepot + public Task?> GetPartnerDepots() => GetAllAsync>(SignalRTags.GetPartnerDepots); + public Task GetPartnerDepotById(int id) => GetByIdAsync(SignalRTags.GetPartnerDepotById, id); + public Task?> GetPartnerDepotsByPartnerId(int partnerId) => GetAllAsync>(SignalRTags.GetPartnerDepotsByPartnerId, [partnerId]); + public Task AddPartnerDepot(PartnerDepot partnerDepot) => PostDataAsync(SignalRTags.AddPartnerDepot, partnerDepot); + public Task UpdatePartnerDepot(PartnerDepot partnerDepot) => PostDataAsync(SignalRTags.UpdatePartnerDepot, partnerDepot); + #endregion PartnerDepot + + #region EkaerHistory + public Task?> GetEkaerHistories(EkaerHistoryFilter filter) => GetAllAsync>(SignalRTags.GetEkaerHistories, [filter]); + public Task GetEkaerHistoryById(int id) => GetByIdAsync(SignalRTags.GetEkaerHistoryById, id); + public Task?> GetEkaerHistoriesByForeignKey(int foreignKey) => GetAllAsync>(SignalRTags.GetEkaerHistoriesByForeignKey, [foreignKey]); + public Task AddEkaerHistory(EkaerHistory ekaerHistory) => PostDataAsync(SignalRTags.AddEkaerHistory, ekaerHistory); + public Task UpdateEkaerHistory(EkaerHistory ekaerHistory) => PostDataAsync(SignalRTags.UpdateEkaerHistory, ekaerHistory); + public Task GenerateEkaerXmlDocument(int ekaerHistoryId) => GetByIdAsync(SignalRTags.GenerateEkaerXmlDocument, ekaerHistoryId); + public Task CreateEkaerHistory(int foreignKey, bool isOutgoing) => GetByIdAsync(SignalRTags.CreateEkaerHistory, [foreignKey, isOutgoing]); + public Task CreateMissingEkaerHistories(DateTime fromDate) => GetByIdAsync(SignalRTags.CreateMissingEkaerHistories, fromDate); + public Task GetEkaerHistoryCount(EkaerHistoryFilter filter) => GetByIdAsync(SignalRTags.GetEkaerHistoryCount, filter); + #endregion EkaerHistory + #region CargoPartner public Task?> GetCargoPartners() => GetAllAsync>(SignalRTags.GetCargoPartners); public Task GetCargoPartnerById(int id) => GetByIdAsync(SignalRTags.GetCargoPartnerById, id); @@ -163,10 +182,10 @@ namespace FruitBankHybrid.Shared.Services.SignalRs => GetAllAsync>(SignalRTags.GetShippingDocumentsByShippingId, [shippingId]); public Task?> GetShippingDocumentsByProductId(int productId) - => GetAllAsync>(SignalRTags.GetShippingDocumentsByShippingId, [productId]); + => GetAllAsync>(SignalRTags.GetShippingDocumentsByProductId, [productId]); public Task?> GetShippingDocumentsByPartnerId(int partnerId) - => GetAllAsync>(SignalRTags.GetShippingDocumentsByShippingId, [partnerId]); + => GetAllAsync>(SignalRTags.GetShippingDocumentsByPartnerId, [partnerId]); public Task AddShippingDocument(ShippingDocument shippingDocument) => PostDataAsync(SignalRTags.AddShippingDocument, shippingDocument); @@ -185,9 +204,15 @@ namespace FruitBankHybrid.Shared.Services.SignalRs #endregion Customer #region Product - + //TODO: itt ProductDtoTableItem kéne, csak az AycodeHubProtocol ProductDto-ra deserialize-ol! - J. + //public Task?> GetProductDtoTableItems() => GetAllAsync>(SignalRTags.GetProductDtos); + // IDEIGLENES gyors fix: a GetProductDtos tagon a szerver List-t küld, a binár + // protokoll viszont a wire-típusba (ProductDto) deszerializál — így a közvetlen + // List kérés üres lett (a ProductDtoTableItem a ProductDto kliens-oldali + // leszármazottja, amit a szerver nem ismer). A működő GetProductDtos()-t hívjuk, és bináris + // round-trippel a kért leszármazottra alakítjuk (azonos mezők). VÉGLEGES megoldás: requestId → + // kért-típus a protokollban (AQN-leváltás) — lásd AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL. public async Task?> GetProductDtoTableItems() - //=> GetAllAsync>(SignalRTags.GetProductDtos); { var dtos = await GetProductDtos(); if (dtos == null) return null; @@ -196,14 +221,12 @@ namespace FruitBankHybrid.Shared.Services.SignalRs return SignalRSerializationHelper.DeserializeFromBinary>(bytes); } - public Task?> GetProductDtos() - => GetAllAsync>(SignalRTags.GetProductDtos); + public Task?> GetProductDtos() => GetAllAsync>(SignalRTags.GetProductDtos); //public Task?> GetAllMeasuringProductDtos() // => GetAllAsync>(SignalRTags.GetAllMeasuringProductDtos); - public Task GetProductDtoById(int productId) - => GetByIdAsync(SignalRTags.GetMeasuringProductDtoById, productId); + public Task GetProductDtoById(int productId) => GetByIdAsync(SignalRTags.GetMeasuringProductDtoById, productId); #endregion Product diff --git a/FruitBankHybrid.Shared/Services/SignalRs/SignalRDataSource.cs b/FruitBankHybrid.Shared/Services/SignalRs/SignalRDataSource.cs index 211341b0..29be99db 100644 --- a/FruitBankHybrid.Shared/Services/SignalRs/SignalRDataSource.cs +++ b/FruitBankHybrid.Shared/Services/SignalRs/SignalRDataSource.cs @@ -1,7 +1,6 @@ using AyCode.Core.Helpers; using AyCode.Core.Interfaces; using AyCode.Core.Loggers; -using AyCode.Services.Server.SignalRs; using AyCode.Services.SignalRs; using System.Collections.ObjectModel; using System.Diagnostics; diff --git a/FruitBankHybrid.Shared/appsettings.json b/FruitBankHybrid.Shared/appsettings.json index a14ace2d..bbe2d171 100644 --- a/FruitBankHybrid.Shared/appsettings.json +++ b/FruitBankHybrid.Shared/appsettings.json @@ -42,6 +42,6 @@ "ProtocolMode": "Bytes", "BufferSize": 4096, "FlushPolicy": "Coalesced", - "FlushTimeout": "00:00:10" + "FlushTimeout": "00:00:15" } } diff --git a/FruitBankHybrid.Shared/wwwroot/app.css b/FruitBankHybrid.Shared/wwwroot/app.css index 71e41aa0..ba0dd004 100644 --- a/FruitBankHybrid.Shared/wwwroot/app.css +++ b/FruitBankHybrid.Shared/wwwroot/app.css @@ -64,6 +64,22 @@ h1:focus { --icon-orders-mask-image: url("images/orders-fluent.svg"); --icon-shippings-mask-image: url("images/shippings-fluent.svg"); --icon-login-mask-image: url("images/login-fluent.svg"); + --icon-ekaer-mask-image: url("images/ekaer-fluent.svg"); +} + +/* EKÁER toolbar dátumválasztó (GridEkaerHistory) — kompakt szélesség, hogy illeszkedjen a toolbar-elemek közé. */ +.ekaer-create-from-date { + width: 9rem; +} + +/* A Template-es toolbar-item tartalma alapból felülre csúszik. Az elsődleges igazítást a DevExpress saját + dxbl-toolbar-edit osztálya adja (a téma-CSS szállítja, a CssClass-ban kapja meg az item) — ez itt a + fallback, ami a markup-szerkezettől függetlenül középre igazít. */ +.ekaer-create-from-date-item, +.ekaer-create-from-date-item > div { + display: flex; + align-items: center; + height: 100%; } .icon { diff --git a/FruitBankHybrid.Shared/wwwroot/images/ekaer-fluent.svg b/FruitBankHybrid.Shared/wwwroot/images/ekaer-fluent.svg new file mode 100644 index 00000000..7c6623f3 --- /dev/null +++ b/FruitBankHybrid.Shared/wwwroot/images/ekaer-fluent.svg @@ -0,0 +1,4 @@ + + + + diff --git a/FruitBankHybrid.Web.Client/FruitBankHybrid.Web.Client.csproj b/FruitBankHybrid.Web.Client/FruitBankHybrid.Web.Client.csproj index 6e6e48c1..62501ae0 100644 --- a/FruitBankHybrid.Web.Client/FruitBankHybrid.Web.Client.csproj +++ b/FruitBankHybrid.Web.Client/FruitBankHybrid.Web.Client.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -7,7 +7,7 @@ true Default - false + true true true @@ -58,33 +58,18 @@ ..\..\..\..\Aycode\Source\AyCode.Core\AyCode.Services.Server\bin\FruitBank\$(Configuration)\net9.0\AyCode.Core.dll - - ..\..\..\..\Aycode\Source\AyCode.Core\AyCode.Services.Server\bin\FruitBank\$(Configuration)\net9.0\AyCode.Core.Server.dll - ..\..\..\..\Aycode\Source\AyCode.Core\AyCode.Services.Server\bin\FruitBank\$(Configuration)\net9.0\AyCode.Entities.dll - - ..\..\..\..\Aycode\Source\AyCode.Core\AyCode.Services.Server\bin\FruitBank\$(Configuration)\net9.0\AyCode.Entities.Server.dll - ..\..\..\..\Aycode\Source\AyCode.Core\AyCode.Services.Server\bin\FruitBank\$(Configuration)\net9.0\AyCode.Interfaces.dll - - ..\..\..\..\Aycode\Source\AyCode.Core\AyCode.Services.Server\bin\FruitBank\$(Configuration)\net9.0\AyCode.Interfaces.Server.dll - ..\..\..\..\Aycode\Source\AyCode.Core\AyCode.Services.Server\bin\FruitBank\$(Configuration)\net9.0\AyCode.Models.dll - - ..\..\..\..\Aycode\Source\AyCode.Core\AyCode.Services.Server\bin\FruitBank\$(Configuration)\net9.0\AyCode.Models.Server.dll - ..\..\..\..\Aycode\Source\AyCode.Core\AyCode.Services.Server\bin\FruitBank\$(Configuration)\net9.0\AyCode.Services.dll - - ..\..\..\..\Aycode\Source\AyCode.Core\AyCode.Services.Server\bin\FruitBank\$(Configuration)\net9.0\AyCode.Services.Server.dll - ..\..\..\..\Aycode\Source\AyCode.Core\AyCode.Services.Server\bin\FruitBank\$(Configuration)\net9.0\AyCode.Utils.dll @@ -94,7 +79,7 @@ - ..\..\NopCommerce.Common\4.70\Libraries\Mango.Nop.Core\bin\FruitBank\$(Configuration)\net9.0\Mango.Nop.Core.dll + ..\..\NopCommerce.Common\4.70\Libraries\Mango.Nop.Core\bin\FruitBank\$(Configuration)\net9.0\Mango.Nop.Core.dll diff --git a/FruitBankHybrid.Web.Client/wwwroot/appsettings.json b/FruitBankHybrid.Web.Client/wwwroot/appsettings.json index a14ace2d..bbe2d171 100644 --- a/FruitBankHybrid.Web.Client/wwwroot/appsettings.json +++ b/FruitBankHybrid.Web.Client/wwwroot/appsettings.json @@ -42,6 +42,6 @@ "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 a14ace2d..bbe2d171 100644 --- a/FruitBankHybrid.Web/appsettings.json +++ b/FruitBankHybrid.Web/appsettings.json @@ -42,6 +42,6 @@ "ProtocolMode": "Bytes", "BufferSize": 4096, "FlushPolicy": "Coalesced", - "FlushTimeout": "00:00:10" + "FlushTimeout": "00:00:15" } } diff --git a/FruitBankHybrid/FruitBankHybrid.csproj b/FruitBankHybrid/FruitBankHybrid.csproj index f56695ec..0c331910 100644 --- a/FruitBankHybrid/FruitBankHybrid.csproj +++ b/FruitBankHybrid/FruitBankHybrid.csproj @@ -23,7 +23,7 @@ 1 $(ApplicationDisplayVersion) - false + true true true @@ -47,9 +47,9 @@ - + true diff --git a/SqlSchemaCompare_Dev_to_Prod.scmp b/SqlSchemaCompare_Dev_to_Prod.scmp index f22fcf7e..29d855b1 100644 --- a/SqlSchemaCompare_Dev_to_Prod.scmp +++ b/SqlSchemaCompare_Dev_to_Prod.scmp @@ -3,12 +3,12 @@ 10 - Data Source=195.26.231.218;Initial Catalog=FruitBank_DEV;Integrated Security=False;Persist Security Info=False;User ID=sa;Pooling=False;Multiple Active Result Sets=False;Trust Server Certificate=True + Data Source=100.73.220.50;Initial Catalog=FruitBank_DEV;Integrated Security=False;Persist Security Info=False;User ID=sa;Pooling=False;Trust Server Certificate=True - Data Source=195.26.231.218;Initial Catalog=FruitBank_PROD;Integrated Security=False;Persist Security Info=False;User ID=sa;Pooling=False;Multiple Active Result Sets=False;Trust Server Certificate=True + Data Source=100.73.220.50;Initial Catalog=FruitBank_PROD;Integrated Security=False;Persist Security Info=False;User ID=sa;Pooling=False;Trust Server Certificate=True @@ -357,7 +357,7 @@ TargetConnectionString - Data Source=195.26.231.218;Initial Catalog=FruitBank_PROD;Integrated Security=False;Persist Security Info=False;User ID=sa;Pooling=False;Multiple Active Result Sets=False;Trust Server Certificate=True;Application Name="Microsoft SQL Server Data Tools, Schema Compare" + Data Source=100.73.220.50;Initial Catalog=FruitBank_PROD;Integrated Security=False;Persist Security Info=False;User ID=sa;Pooling=False;Multiple Active Result Sets=False;Trust Server Certificate=True;Application Name="Microsoft SQL Server Data Tools, Schema Compare" TreatVerificationErrorsAsWarnings @@ -1002,17 +1002,13 @@ dbo PK_AvalaraItemClassification - - dbo - FacebookPixelConfiguration - dbo PK_FacebookPixelConfiguration dbo - GoogleAuthenticatorRecord + FacebookPixelConfiguration dbo @@ -1020,12 +1016,16 @@ dbo - TaxTransactionLog + GoogleAuthenticatorRecord dbo PK_TaxTransactionLog + + dbo + TaxTransactionLog + dbo vOrder