Refactor EKÁER history creation & update docs

- Refactored CreateMissingEkaerHistories to return detailed results (created count + messages), improved grouping, obligation checks, and error handling for both inbound and outbound records.
- Updated AcBinaryHubProtocol to use Bytes mode and set FlushTimeout.
- Enhanced documentation: clarified tradeType and carrier mapping, explained threshold logic and reconciliation workflow, and warned about over-reporting risks.
- Minor code and comment cleanups for clarity.
This commit is contained in:
Loretta 2026-06-15 11:53:00 +02:00
parent 3bde0b4063
commit 24164b5189
4 changed files with 87 additions and 34 deletions

View File

@ -363,13 +363,11 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
// A forrás-entitás léte irányfüggő: bejövő → ShippingDocument, kimenő → Order.
if (!isOutgoing)
{
_ = await ctx.ShippingDocuments.GetByIdAsync(foreignKey, false)
?? throw new ArgumentException($"ShippingDocument not found; id: {foreignKey}", nameof(foreignKey));
_ = await ctx.ShippingDocuments.GetByIdAsync(foreignKey, false) ?? throw new ArgumentException($"ShippingDocument not found; id: {foreignKey}", nameof(foreignKey));
}
else
{
_ = await ctx.OrderDtos.GetByIdAsync(foreignKey, false)
?? throw new ArgumentException($"Order not found; id: {foreignKey}", nameof(foreignKey));
_ = await ctx.OrderDtos.GetByIdAsync(foreignKey, false) ?? throw new ArgumentException($"Order not found; id: {foreignKey}", nameof(foreignKey));
}
var ekaerHistory = new EkaerHistory { ForeignKey = foreignKey, IsOutgoing = isOutgoing, Status = EkaerStatus.Pending };
@ -384,12 +382,15 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
/// idempotens, és bármikor újrafuttatható (maga a gomb a "reconciliation").
/// </summary>
[SignalR(SignalRTags.CreateMissingEkaerHistories)]
public async Task<int> CreateMissingEkaerHistories(DateTime fromDate)
public async Task<EkaerCreateResult> CreateMissingEkaerHistories(DateTime fromDate)
{
_logger.Detail($"CreateMissingEkaerHistories invoked; fromDate: {fromDate:yyyy-MM-dd}");
// Bejövő: rekord nélküli szállítólevelek a dátumtól — Partnerrel és tételekkel betöltve a kapuhoz
// (IsEkaer-mentesség + tömeg/érték küszöb). A fájl-blobokat NEM töltjük (csak Partner + Items).
var toCreate = new List<EkaerHistory>();
var messages = new HashSet<string>(); // dedup: ugyanaz a partner (pl. hiányzó országkód) több csoportban is előjöhet
// ── Bejövő ── Rekord nélküli szállítólevelek a dátumtól, Partnerrel + tételekkel betöltve a kötelezettség-kapuhoz.
// A bejelentés-egység (Shipping, Partner) = feladó→címzett→jármű: a küszöb a csoport ÖSSZES dokumentumára aggregál.
var candidates = await ctx.ShippingDocuments.GetAll()
.Where(sd => sd.ShippingDate >= fromDate)
.Where(sd => !ctx.EkaerHistories.Table.Any(eh => eh.ForeignKey == sd.Id && !eh.IsOutgoing))
@ -397,33 +398,58 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
.LoadWith(sd => sd.ShippingItems)
.ToListAsync();
var missingInbound = new List<EkaerHistory>();
foreach (var doc in candidates)
// Partner nélküli (még nincs rendesen felvett) szállítólevél CSENDBEN kihagyva — nincs feladó, nem kész;
// a mentes partner (IsEkaer=false) is. A maradék (Shipping, Partner) csoportban (a dokumentum mindig kap Shippinget).
foreach (var group in candidates.Where(sd => sd.Partner != null && sd.Partner.IsEkaer != false).GroupBy(sd => (sd.ShippingId, sd.PartnerId)))
{
// Explicit mentesítés (pl. nagybani piac, azonos cím — nincs közúti fuvar a partnerek között).
if (doc.Partner?.IsEkaer == false) continue;
var docs = group.ToList();
try
{
var result = fruitBankEkaerService.EvaluateObligation(docs);
if (result.Obligation == EkaerObligation.DataError) { messages.UnionWith(result.Errors); continue; }
if (result.Obligation == EkaerObligation.NotRequired) continue;
var items = doc.ShippingItems ?? [];
var totalWeight = items.Sum(i => i.MeasuredGrossWeight);
var rateToHuf = EkaerValueCalculator.ResolveRateToHuf(doc.Partner?.Currency, ekaerSettings.EurHufRate);
var totalValueHuf = items.Sum(EkaerValueCalculator.ItemLineValue) * rateToHuf;
// Küszöb alatt (tömeg ÉS érték is) → nem kötelező EKÁER → nem hozunk létre sort.
if (totalWeight < ekaerSettings.ThresholdWeightKg && totalValueHuf < ekaerSettings.ThresholdValueHuf) continue;
missingInbound.Add(new EkaerHistory { ForeignKey = doc.Id, IsOutgoing = false, StatusId = (int)EkaerStatus.Pending });
// Kötelező → a csoport minden dokumentumára egy-egy Pending sor (a rekord-szerkezet per-dokumentum marad).
foreach (var doc in docs)
toCreate.Add(new EkaerHistory { ForeignKey = doc.Id, IsOutgoing = false, StatusId = (int)EkaerStatus.Pending });
}
catch (Exception ex)
{
_logger.Error($"CreateMissingEkaerHistories; inbound evaluate failed; ShippingId: {group.Key.ShippingId}; PartnerId: {group.Key.PartnerId}", ex);
messages.Add($"Szállítólevél-csoport (Shipping #{group.Key.ShippingId}, Partner #{group.Key.PartnerId}): a kötelezettség nem dönthető el — {ex.Message}");
}
}
// Kimenő: rekord nélküli, lezárt (Complete) rendelések, DateOfReceipt a dátumtól (jövőbeli is — előre-bejelentés).
var missingOutgoing = await ctx.OrderDtos.GetAllByOrderStatus(OrderStatus.Complete, false)
// ── Kimenő ── Rekord nélküli, lezárt (Complete) rendelések a dátumtól; rendelésenként vizsgáljuk (nincs Shipping).
// Előbb csak az azonosítók (könnyű szűrés), majd EGY batch-betöltés a teljes gráffal (GetAllByIds) — nincs N+1.
var missingOrderIds = await ctx.OrderDtos.GetAllByOrderStatus(OrderStatus.Complete, false)
.Where(o => o.GenericAttributes.Any(ga => ga.Key == nameof(OrderDto.DateOfReceipt) && DateTime.Parse(ga.Value) >= fromDate.Date))
.Where(o => !ctx.EkaerHistories.Table.Any(eh => eh.ForeignKey == o.Id && eh.IsOutgoing))
.Select(o => new EkaerHistory { ForeignKey = o.Id, IsOutgoing = true, StatusId = (int)EkaerStatus.Pending })
.Select(o => o.Id)
.ToListAsync();
var createdCount = 0;
var missingOrders = missingOrderIds.Count == 0 ? [] : await ctx.OrderDtos.GetAllByIds(missingOrderIds, true).ToListAsync();
foreach (var ekaerHistory in missingInbound.Concat(missingOutgoing))
foreach (var order in missingOrders)
{
try
{
var result = fruitBankEkaerService.EvaluateObligation(order);
if (result.Obligation == EkaerObligation.DataError) { messages.UnionWith(result.Errors); continue; }
if (result.Obligation == EkaerObligation.NotRequired) continue;
toCreate.Add(new EkaerHistory { ForeignKey = order.Id, IsOutgoing = true, StatusId = (int)EkaerStatus.Pending });
}
catch (Exception ex)
{
_logger.Error($"CreateMissingEkaerHistories; outbound evaluate failed; OrderId: {order.Id}", ex);
messages.Add($"Rendelés #{order.Id}: a kötelezettség nem dönthető el — {ex.Message}");
}
}
// ── Beszúrás ──
var createdCount = 0;
foreach (var ekaerHistory in toCreate)
{
try
{
@ -436,8 +462,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
}
}
_logger.Info($"CreateMissingEkaerHistories; created: {createdCount} (inbound: {missingInbound.Count}, outgoing: {missingOutgoing.Count}); fromDate: {fromDate:yyyy-MM-dd}");
return createdCount;
_logger.Info($"CreateMissingEkaerHistories; created: {createdCount}; messages: {messages.Count}; fromDate: {fromDate:yyyy-MM-dd}");
return new EkaerCreateResult { CreatedCount = createdCount, Messages = [.. messages.OrderBy(m => m)] };
}
[SignalR(SignalRTags.GetShippings)]

View File

@ -259,8 +259,10 @@ public class PluginNopStartup : INopStartup
})
.AddAcBinaryProtocol(opts =>
{
opts.ProtocolMode = BinaryProtocolMode.AsyncSegment;
//opts.ProtocolMode = BinaryProtocolMode.AsyncSegment;
opts.ProtocolMode = BinaryProtocolMode.Bytes;
opts.FlushPolicy = FlushPolicy.Coalesced;
opts.FlushTimeout = TimeSpan.FromSeconds(15);
// Explicit AcLogger instance (name-based category, matches the previous setup).
// If omitted, the options extension falls back to ILogger<AcBinaryHubProtocol> from DI.

File diff suppressed because one or more lines are too long

View File

@ -11,7 +11,7 @@ A FruitBank EKÁER-bejelentés **szerver-oldali** (a kliens már standalone WASM
| Réteg | Hol | Mit ad |
|---|---|---|
| **NAV/EKÁER framework** | `AyCode.Services/Nav/` + `Nav/Ekaer/` (AyCode.Core) | transport, auth (SHA-512), generált modellek, **`EkaerTradeCardValidator`** (NAV-séma + üzleti szabályok), **`EkaerSubmitService`** (validate→send), `EkaerManageService` (HTTP). Általános — **nem** FruitBank-specifikus. |
| **FruitBank leképezés** | `FruitBank.Common/Services/Ekaer/` | **`ShippingToEkaerMapper`** — `Shipping``tradeCard` ops (tiszta leképező, nem validál) |
| **FruitBank leképezés** | `FruitBank.Common/Services/Ekaer/` | **`ShippingToEkaerMapper`** — a forrást (bejövő `ShippingDocument`-csoport / kimenő `OrderDto`) egy irány-független **`EkaerConsignment`**-re képezi (`ToConsignment`), abból épül a `tradeCard` (`BuildTradeCard`). **`EkaerReportability`** — bejelentés-kötelezettség (küszöb/külföld). **`EkaerValueCalculator`** — tétel-érték HUF-ban. |
| **FruitBank fogyasztó** | `FruitBank.Common.Server/Services/Ekaer/` | **`FruitBankEkaerService`** — szerver-oldali orchestráció: credentials/options + map → submit |
| **nopCommerce-integráció (ez a plugin)** | ez a folder + a plugin kód | DI-bekötés, NAV-fiók settings, a VTSZ-forrás (`Product.Gtin`), SignalR-trigger a kliens felől, és ez a doksi |
@ -26,6 +26,10 @@ FruitBankEkaerService.SubmitShippingAsync(shipping) // FruitBank.Common.S
```
Validációs hiba → **hibalista** (`EkaerSubmitResult.Invalid`), nem megy ki kérés. NAV-oldali hiba → `NavReportException` propagál.
**További belépési pontok** (a kézi NAV-beadás munkafolyamathoz — `FruitBankDataController` SignalR-endpointok, kliens felől triggerelve):
- **`CreateMissingEkaerHistories(fromDate)`** — reconciliation-kapu: a dátumtól rekord nélküli szállítólevelekre/rendelésekre `Pending` `EkaerHistory` sorokat hoz létre, **csak ha kötelező** (lásd a kapu-szakaszt lentebb). `EkaerCreateResult`-ot ad vissza (létrehozott szám + a kihagyott tételek üzenetei).
- **`GenerateEkaerXmlDocument(foreignKey, isOutgoing)`** — egy sorra legenerálja + validálja a tradeCard XML-t (NEM küld NAV-ot); a `Status` / `ErrorText` / `ConversionRate` töltődik, az XML vágólapra másolható a kézi beadáshoz.
## Companion fájlok
- [`EKAER_ISSUES.md`](EKAER_ISSUES.md) — ismert problémák (pl. `MGFBANKPLUG-EKAER-I-T3X8`: GTIN≠VTSZ).
@ -36,7 +40,7 @@ Validációs hiba → **hibalista** (`EkaerSubmitResult.Invalid`), nem megy ki k
| EKÁER | FruitBank forrás |
|---|---|
| `seller*` (feladó) | `ShippingDocument.Partner` (a beszállító) |
| `carrierText` (fuvarozó, szöveges) | `Shipping.CargoPartner.Name` |
| `carrierText` (fuvarozó, szöveges) | **bejövő:** `Shipping.CargoPartner.Name`; **kimenő:** `Customer.Company` (a vevő viszi el az árut) |
| `vehicle` / `vehicle2` | `Shipping.CargoTruck` / `CargoTrailer` (`LicencePlate` + `CountryCode`, normalizálva) |
| tétel `productVtsz` | `ShippingItem.ProductDto.Gtin` (átmenetileg — lásd `EKAER_ISSUES.md`) |
| tétel `productName` | `ShippingItem.ProductName` |
@ -45,6 +49,23 @@ Validációs hiba → **hibalista** (`EkaerSubmitResult.Invalid`), nem megy ki k
A nyitott pontok (tradeType, destination, value/deviza, granularitás, kimenő irány): [`EKAER_TODO.md`](EKAER_TODO.md).
## Reconciliation-kapu, kötelezettség, életciklus (2026-06)
A kézi NAV-beadás munkafolyamat: a **kapu** létrehozza a sorokat → a user **generál** + másol/beküld → kézzel rögzíti a NAV-választ.
**`EkaerHistory` életciklus** (egy sor = egy tradeCard; `ForeignKey` = bejövő `ShippingDocument.Id` / kimenő `Order.Id` + `IsOutgoing`; a `Status` int-oszlop + `[NotColumn]` enum-nézet):
`Pending` (a kapu hozta létre) → **Generate**`Generated` / `GeneratedWithWarning` (küldhető, de pótlandó — pl. hiányzó rendszám) / `ValidationError` (blokkoló) → kézi NAV-beadás után `Sent` / `SentWithMissingData` (pótlásra vár). A grid 3 tabja ezekre szűr (szerver-oldali `EkaerHistoryFilter`), a warning ⚠️ / error ⛔ ikonnal megkülönböztetve.
**Bejelentés-kötelezettség** — `EkaerReportability.Evaluate(consignment, settings)`, a kapu hívja egységenként:
- **egység:** bejövőnél a `(Shipping, Partner)` csoport (= feladó→címzett→jármű) — a tömeg/érték a csoport **ÖSSZES** dokumentumára **aggregálva** (13/2020 PM rendelet); kimenőnél egy rendelés;
- **küszöb felett** → kötelező, az országkódtól **függetlenül** (az országkód-hibát a generate-validálás jelzi);
- **küszöb alatt** → csak a **külföldi** reláció kötelező (a feladó és a címzett országkódja **eltér**); belföld → nem; hiányzó/érvénytelen (nem ISO-2) országkód → **`DataError`** (a sor kimarad + üzenet a popupban);
- **mentesség:** `Partner.IsEkaer = false`, és a Partner nélküli (félkész) szállítólevél — csendben kimaradnak.
> **A küszöb configból jön** (`Ekaer:Thresholds:WeightKg` / `:ValueHuf`) — a kódba **NINCS drótozva**, mert a NAV-jogszabály és a beállítás szerint **változhat**. A konkrét, dátumozott értékeket és a termékkategóriákat lásd [`EKAER_TODO.md`](EKAER_TODO.md) **T-W3R8**. A NAV **mindkét irányban büntet** (alul- ÉS túljelentés), ezért a küszöbnek pontosnak kell lennie.
**Egyesített leképezés:** a bejövő/kimenő forrás egy **`EkaerConsignment`** köztes modellre képződik (két adapter), és **abból** épül MIND a tradeCard (`BuildTradeCard`), MIND a kötelezettség-döntés — így az irányfüggő rész két adapterre szorul, a közös logika egy helyen van. A `MapDocument` / `MapOrder` / `MapShipping` ennek vékony wrappere.
## Bekötés a szerveren (DI)
A plugin (vagy a szerver-startup) köti be a láncot — a NAV-fiók titkos adatai a szerver config/secret store-jából:
@ -54,6 +75,6 @@ services.AddHttpClient<EkaerManageService>(/* NAV TLS 1.2 */);
services.AddScoped<IEkaerTradeCardValidator, EkaerTradeCardValidator>();
services.AddScoped<IEkaerSubmitService, EkaerSubmitService>();
services.AddScoped<IShippingToEkaerMapper, ShippingToEkaerMapper>();
services.AddSingleton(/* EkaerMappingOptions: destination + saját raktár */);
services.AddSingleton<IEkaerSettings>(/* EkaerSettings: Ekaer:Company + Site + küszöbök + EUR-HUF árfolyam — configból */);
services.AddScoped<IFruitBankEkaerService, FruitBankEkaerService>();
```