diff --git a/Nop.Plugin.Misc.AIPlugin/.github/copilot-instructions.md b/Nop.Plugin.Misc.AIPlugin/.github/copilot-instructions.md index 4089bf5..1d77eb9 100644 --- a/Nop.Plugin.Misc.AIPlugin/.github/copilot-instructions.md +++ b/Nop.Plugin.Misc.AIPlugin/.github/copilot-instructions.md @@ -61,7 +61,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/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs b/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs index 395fcdf..19bf8b2 100644 --- a/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using AyCode.Core.Extensions; using AyCode.Core.Loggers; +using AyCode.Services.Nav.Ekaer; using AyCode.Services.SignalRs; using DocumentFormat.OpenXml.Office2010.Excel; using FruitBank.Common.Dtos; @@ -13,6 +14,8 @@ using FruitBank.Common.Loggers; using FruitBank.Common.Models; using FruitBank.Common.Server; using FruitBank.Common.Server.Interfaces; +using FruitBank.Common.Server.Services.Ekaer; +using FruitBank.Common.Services.Ekaer; using FruitBank.Common.SignalRs; using LinqToDB; using Mango.Nop.Core.Dtos; @@ -20,6 +23,7 @@ using Mango.Nop.Core.Loggers; using Mango.Nop.Core.Models; using Nop.Core; using Nop.Core.Domain.Customers; +using Nop.Core.Domain.Orders; using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; using Nop.Plugin.Misc.FruitBankPlugin.Factories; using Nop.Plugin.Misc.FruitBankPlugin.Services; @@ -40,6 +44,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers ICustomerRegistrationService customerRegistrationService, ILocalizationService localizationService, PreOrderConversionService preorderConversionService, + IFruitBankEkaerService fruitBankEkaerService, + IEkaerSettings ekaerSettings, IEnumerable logWriters) : BasePluginController, IFruitBankDataControllerServer { @@ -101,7 +107,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers { _logger.Detail($"GetPartners invoked"); - return await ctx.Partners.GetAll().ToListAsync(); + // A telephelyeket is behúzzuk (CSAK a depókat, NEM a teljes GetAll(true) láncot) — a partnerek + // úgyis cache-eltek, így a ShippingDocument-grid telephely-legördülője kliens-oldalon kaszkádolhat. + return await ctx.Partners.GetAll().LoadWith(p => p.PartnerDepots).ToListAsync(); } [SignalR(SignalRTags.GetPartnerById)] @@ -112,7 +120,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers //var customers = await ctx.GetCustormersBySystemRoleName("Measuring").ToListAsync(); //_logger.Error($"COUNT: {customers.Count}"); - return await ctx.Partners.GetByIdAsync(id); + return await ctx.Partners.GetByIdAsync(id, false); } [SignalR(SignalRTags.AddPartner)] @@ -219,6 +227,274 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers return await ctx.CargoTrucks.GetByIdAsync(cargoTruck.Id, true); } + [SignalR(SignalRTags.GetPartnerDepots)] + public async Task> GetPartnerDepots() + { + _logger.Detail($"GetPartnerDepots invoked"); + + return await ctx.PartnerDepots.GetAll(true).ToListAsync(); + } + + [SignalR(SignalRTags.GetPartnerDepotById)] + public async Task GetPartnerDepotById(int id) + { + _logger.Detail($"GetPartnerDepotById invoked; id: {id}"); + return await ctx.PartnerDepots.GetByIdAsync(id, true); + } + + [SignalR(SignalRTags.GetPartnerDepotsByPartnerId)] + public async Task> GetPartnerDepotsByPartnerId(int partnerId) + { + _logger.Detail($"GetPartnerDepotsByPartnerId invoked; partnerId: {partnerId}"); + return await ctx.PartnerDepots.GetByPartnerId(partnerId, true).ToListAsync(); + } + + [SignalR(SignalRTags.AddPartnerDepot)] + public async Task AddPartnerDepot(PartnerDepot partnerDepot) + { + ArgumentNullException.ThrowIfNull(partnerDepot); + + _logger.Detail($"AddPartnerDepot invoked; id: {partnerDepot.Id}"); + + await ctx.PartnerDepots.InsertAsync(partnerDepot); + return await ctx.PartnerDepots.GetByIdAsync(partnerDepot.Id, true); + } + + [SignalR(SignalRTags.UpdatePartnerDepot)] + public async Task UpdatePartnerDepot(PartnerDepot partnerDepot) + { + ArgumentNullException.ThrowIfNull(partnerDepot); + + _logger.Detail($"UpdatePartnerDepot invoked; id: {partnerDepot.Id}"); + + await ctx.PartnerDepots.UpdateAsync(partnerDepot); + return await ctx.PartnerDepots.GetByIdAsync(partnerDepot.Id, true); + } + + [SignalR(SignalRTags.GetEkaerHistories)] + public async Task> GetEkaerHistories(EkaerHistoryFilter ekaerHistoryFilter) + { + _logger.Detail($"GetEkaerHistories invoked; ekaerHistoryFilter: {ekaerHistoryFilter}"); + return await ctx.EkaerHistories.GetByFilter(ekaerHistoryFilter, true).ToListAsync(); + } + + [SignalR(SignalRTags.GetEkaerHistoryCount)] + public async Task GetEkaerHistoryCount(EkaerHistoryFilter ekaerHistoryFilter) + { + _logger.Detail($"GetEkaerHistoryCount invoked; ekaerHistoryFilter: {ekaerHistoryFilter}"); + return await ctx.EkaerHistories.CountByFilterAsync(ekaerHistoryFilter); + } + + [SignalR(SignalRTags.GetEkaerHistoryById)] + public async Task GetEkaerHistoryById(int id) + { + _logger.Detail($"GetEkaerHistoryById invoked; id: {id}"); + return await ctx.EkaerHistories.GetByIdAsync(id, true); + } + + [SignalR(SignalRTags.GetEkaerHistoriesByForeignKey)] + public async Task> GetEkaerHistoriesByForeignKey(int foreignKey) + { + _logger.Detail($"GetEkaerHistoriesByForeignKey invoked; foreignKey: {foreignKey}"); + + // A forrás-rekordot (ShippingDocument/Order Id) lefedő EKÁER-deklaráció(k), a Mappings-szel betöltve — EGY query + // (EXISTS a mapping-táblára), nem kettő. + return await ctx.EkaerHistories.GetAll(true) + .Where(eh => ctx.EkaerHistoryMappings.Table.Any(m => m.EkaerHistoryId == eh.Id && m.ForeignKey == foreignKey)) + .ToListAsync(); + } + + [SignalR(SignalRTags.AddEkaerHistory)] + public async Task AddEkaerHistory(EkaerHistory ekaerHistory) + { + ArgumentNullException.ThrowIfNull(ekaerHistory); + + _logger.Detail($"AddEkaerHistory invoked; id: {ekaerHistory.Id}"); + + await ctx.EkaerHistories.InsertAsync(ekaerHistory); + return await ctx.EkaerHistories.GetByIdAsync(ekaerHistory.Id, true); + } + + [SignalR(SignalRTags.UpdateEkaerHistory)] + public async Task UpdateEkaerHistory(EkaerHistory ekaerHistory) + { + ArgumentNullException.ThrowIfNull(ekaerHistory); + + _logger.Detail($"UpdateEkaerHistory invoked; id: {ekaerHistory.Id}"); + + await ctx.EkaerHistories.UpdateAsync(ekaerHistory); + return await ctx.EkaerHistories.GetByIdAsync(ekaerHistory.Id, true); + } + + [SignalR(SignalRTags.GenerateEkaerXmlDocument)] + public async Task GenerateEkaerXmlDocument(int ekaerHistoryId) + { + _logger.Detail($"GenerateEkaerXmlDocument invoked; ekaerHistoryId: {ekaerHistoryId}"); + + var ekaerHistory = await ctx.EkaerHistories.GetByIdAsync(ekaerHistoryId, true) + ?? throw new ArgumentException($"EkaerHistory not found; id: {ekaerHistoryId}", nameof(ekaerHistoryId)); + + // A deklarációhoz tartozó forrás-rekordok a BETÖLTÖTT mappingből (bejövő: N szállítólevél; kimenő: 1 rendelés). + var foreignKeys = ekaerHistory.Mappings?.Select(m => m.ForeignKey).ToList() ?? []; + if (foreignKeys.Count == 0) throw new InvalidOperationException($"EkaerHistory #{ekaerHistoryId} has no mapped source records."); + + if (!ekaerHistory.IsOutgoing) + { + // Bejövő: a csoport ÖSSZES szállítólevele EGY tradeCard-dá (összevont tömeg/érték); GetAll(true) = teljes gráf. + var documents = await ctx.ShippingDocuments.GetAll(true).Where(sd => foreignKeys.Contains(sd.Id)).ToListAsync(); + if (documents.Count == 0) throw new InvalidOperationException($"EkaerHistory #{ekaerHistoryId}: no ShippingDocuments found for the mapped ids."); + ekaerHistory = fruitBankEkaerService.GenerateEkaerXmlDocument(documents, ekaerHistory); + + // Összegző (ShippingDate + Partner) frissítése — a csoporton belül invariáns, így bármelyik dok jó. + fruitBankEkaerService.SetSummary(ekaerHistory, documents[0].ShippingDate, documents[0].Partner?.Name); + } + else + { + // Kimenő: egy rendelés (a GetByIdAsync(true) tölti a Customer-t, tételeket, palettákat (GrossWeight), ProductDto-t). + var order = await ctx.OrderDtos.GetByIdAsync(foreignKeys[0], true) + ?? throw new ArgumentException($"Order not found; id: {foreignKeys[0]}"); + ekaerHistory = fruitBankEkaerService.GenerateEkaerXmlDocument(order, ekaerHistory); + + fruitBankEkaerService.SetSummary(ekaerHistory, order.DateOfReceipt, order.Customer?.Company); + } + + await ctx.EkaerHistories.UpdateAsync(ekaerHistory); + // A friss sort adjuk vissza (frissített Modified + a betöltött Mappings); a sor biztosan létezik (épp updateltük). + return await ctx.EkaerHistories.GetByIdAsync(ekaerHistory.Id, true); + } + + /// + /// Idempotens rekord-létrehozás: ha a forrás-rekordra (foreignKey + isOutgoing) már van mapping → a hozzá tartozó + /// EkaerHistory-t adja vissza érintetlenül; különben új Pending rekordot + EGY mapping sort hoz létre (atomikusan). + /// A generálás (XmlDoc) külön lépés: GenerateEkaerXmlDocument(ekaerHistoryId). + /// + [SignalR(SignalRTags.CreateEkaerHistory)] + public async Task CreateEkaerHistory(int foreignKey, bool isOutgoing) + { + _logger.Detail($"CreateEkaerHistory invoked; foreignKey: {foreignKey}; isOutgoing: {isOutgoing}"); + + // Idempotencia: a forrás-rekordra (azonos irányban) már létező mapping → a hozzá tartozó EkaerHistory, + // a Mappings-szel betöltve (GetAll(true)) — EGY query (EXISTS a mappingre), nincs külön újraolvasás. + var existing = await LinqToDB.AsyncExtensions.FirstOrDefaultAsync( + ctx.EkaerHistories.GetAll(true).Where(eh => eh.IsOutgoing == isOutgoing + && ctx.EkaerHistoryMappings.Table.Any(m => m.EkaerHistoryId == eh.Id && m.ForeignKey == foreignKey))); + + if (existing != null) return existing; + + // A forrás-entitás létét ellenőrizzük és betöltjük (relations) az összegző mezőkhöz (ShippingDate + Partner). + var ekaerHistory = new EkaerHistory { IsOutgoing = isOutgoing, Status = EkaerStatus.Pending }; + if (!isOutgoing) + { + var doc = await ctx.ShippingDocuments.GetByIdAsync(foreignKey, true) ?? throw new ArgumentException($"ShippingDocument not found; id: {foreignKey}", nameof(foreignKey)); + fruitBankEkaerService.SetSummary(ekaerHistory, doc.ShippingDate, doc.Partner?.Name); + } + else + { + var order = await ctx.OrderDtos.GetByIdAsync(foreignKey, true) ?? throw new ArgumentException($"Order not found; id: {foreignKey}", nameof(foreignKey)); + fruitBankEkaerService.SetSummary(ekaerHistory, order.DateOfReceipt, order.Customer?.Company); + } + + // EGY EkaerHistory + EGY mapping sor (egyelemű csoport), atomikusan. + ekaerHistory = await ctx.AddEkaerHistoryWithMappingsAsync(ekaerHistory, [foreignKey]); + + // A friss sort adjuk vissza, a betöltött Mappings-szel; a sor biztosan létezik (épp beszúrtuk). + return await ctx.EkaerHistories.GetByIdAsync(ekaerHistory.Id, true); + } + + /// + /// A megadott dátumtól kezdődő, EKÁER-rekord nélküli szállítólevelekre Pending rekordot hoz létre. + /// User-vezérelt (toolbar gomb) — szándékosan NINCS automata rekord-érzékelés: a létrehozás explicit, + /// idempotens, és bármikor újrafuttatható (maga a gomb a "reconciliation"). + /// + [SignalR(SignalRTags.CreateMissingEkaerHistories)] + public async Task CreateMissingEkaerHistories(DateTime fromDate) + { + _logger.Detail($"CreateMissingEkaerHistories invoked; fromDate: {fromDate:yyyy-MM-dd}"); + + var toCreate = new List<(EkaerHistory History, List ForeignKeys)>(); + var messages = new HashSet(); // 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, PartnerDepot) = feladó(+telephely)→címzett→jármű: EGY EkaerHistory/csoport, 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.EkaerHistoryMappings.Table.Any(m => m.ForeignKey == sd.Id && ctx.EkaerHistories.Table.Any(eh => eh.Id == m.EkaerHistoryId && !eh.IsOutgoing))) + .LoadWith(sd => sd.Partner) + .LoadWith(sd => sd.ShippingItems) + .ToListAsync(); + + // 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, PartnerDepot) 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, sd.PartnerDepotId))) + { + 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; + + // Kötelező → EGY EkaerHistory a csoportra (összegző: ShippingDate + Partner — invariáns) + a dokumentumok mapping-foreignKey-ként. + var history = new EkaerHistory { IsOutgoing = false, StatusId = (int)EkaerStatus.Pending }; + fruitBankEkaerService.SetSummary(history, docs[0].ShippingDate, docs[0].Partner?.Name); + toCreate.Add((history, docs.Select(d => d.Id).ToList())); + } + catch (Exception ex) + { + _logger.Error($"CreateMissingEkaerHistories; inbound evaluate failed; ShippingId: {group.Key.ShippingId}; PartnerId: {group.Key.PartnerId}; PartnerDepotId: {group.Key.PartnerDepotId}", ex); + messages.Add($"Szállítólevél-csoport (Shipping #{group.Key.ShippingId}, Partner #{group.Key.PartnerId}, Telephely #{group.Key.PartnerDepotId?.ToString() ?? "—"}): a kötelezettség nem dönthető el — {ex.Message}"); + } + } + + // ── 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.EkaerHistoryMappings.Table.Any(m => m.ForeignKey == o.Id && ctx.EkaerHistories.Table.Any(eh => eh.Id == m.EkaerHistoryId && eh.IsOutgoing))) + .Select(o => o.Id) + .ToListAsync(); + + var missingOrders = missingOrderIds.Count == 0 ? [] : await ctx.OrderDtos.GetAllByIds(missingOrderIds, true).ToListAsync(); + + 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; + + var history = new EkaerHistory { IsOutgoing = true, StatusId = (int)EkaerStatus.Pending }; + fruitBankEkaerService.SetSummary(history, order.DateOfReceipt, order.Customer?.Company); + toCreate.Add((history, [order.Id])); + } + 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 (history, foreignKeys) in toCreate) + { + try + { + // EkaerHistory + a forrás-mapping sorok atomikusan (a junction tartja a kapcsolatot, nem a rendszer-tábla). + await ctx.AddEkaerHistoryWithMappingsAsync(history, foreignKeys); + createdCount++; + } + catch (Exception ex) + { + _logger.Error($"CreateMissingEkaerHistories; insert failed; IsOutgoing: {history.IsOutgoing}; foreignKeys: [{string.Join(",", foreignKeys)}]", ex); + } + } + + _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)] public async Task> GetShippings() { diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/EkaerHistoryDbTable.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/EkaerHistoryDbTable.cs new file mode 100644 index 0000000..ce7d739 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/EkaerHistoryDbTable.cs @@ -0,0 +1,51 @@ +using FruitBank.Common.Entities; +using LinqToDB; +using Mango.Nop.Data.Repositories; +using Nop.Core.Caching; +using Nop.Core.Configuration; +using Nop.Core.Events; +using Nop.Data; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; + +public class EkaerHistoryDbTable : MgDbTableBase +{ + public EkaerHistoryDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings) + : base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings) + { + } + + // History tábla: legújabb elöl. + public override IOrderedQueryable GetAll() => base.GetAll().OrderByDescending(p => p.Id); + + // loadRelations → a deklarációt lefedő forrás-mapping sorok (Mappings) is töltődnek (a kliensnek kellenek). + public IQueryable GetAll(bool loadRelations) + => loadRelations ? GetAll().LoadWith(eh => eh.Mappings) : GetAll(); + + // FirstOrDefaultAsync kétértelmű (System.Linq.Async + LinqToDB) az IQueryable-ön (a GetAll(loadRelations) LoadWith + // miatt már nem IOrderedQueryable) → explicit a LinqToDB-é, ugyanúgy mint a CountByFilterAsync-nél. + public Task GetByIdAsync(int id, bool loadRelations) => LinqToDB.AsyncExtensions.FirstOrDefaultAsync(GetAll(loadRelations), p => p.Id == id); + + // A NAV-nál lévő partíciók fix StatusId-k; minden más (a jövőbeni új státuszok is) „beküldésre váró". + private static readonly int[] ToSubmitStatusIds = [.. Enum.GetValues().Select(s => (int)s).Except([(int)EkaerStatus.Sent, (int)EkaerStatus.SentWithMissingData])]; + + /// Flag-alapú szűrés a tabokhoz/count-hoz. (= 0) → minden; + /// egyébként a beállított flag-ek diszjunkt StatusId-partícióinak UNIÓJA (IN). A státusz-helperek (IsSent stb.) + /// nem fordulnak SQL-re, ezért közvetlen StatusId-halmazzal szűrünk. + public IQueryable GetByFilter(EkaerHistoryFilter filter, bool loadRelations) + { + // HasFlag(All) mindig true (0 bit), ezért előbb az All-ágat zárjuk rövidre. + if (filter == EkaerHistoryFilter.All) return GetAll(loadRelations); + + var statusIds = new List(); + if (filter.HasFlag(EkaerHistoryFilter.ToSubmit)) statusIds.AddRange(ToSubmitStatusIds); + if (filter.HasFlag(EkaerHistoryFilter.Sent)) statusIds.Add((int)EkaerStatus.Sent); + if (filter.HasFlag(EkaerHistoryFilter.NeedsCompletion)) statusIds.Add((int)EkaerStatus.SentWithMissingData); + + return GetAll(loadRelations).Where(eh => statusIds.Contains(eh.StatusId)); + } + + // A CountAsync kétértelmű (a System.Linq.Async ÉS a LinqToDB is hozza ugyanazt a nevet, no-arg hívásnál mindkettő illik) + // → explicit a LinqToDB-é. (A ToListAsync nem ütközik, azt a hívó közvetlenül használhatja.) + public Task CountByFilterAsync(EkaerHistoryFilter filter) => LinqToDB.AsyncExtensions.CountAsync(GetByFilter(filter, false)); +} diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/EkaerHistoryMappingDbTable.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/EkaerHistoryMappingDbTable.cs new file mode 100644 index 0000000..8cd91f8 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/EkaerHistoryMappingDbTable.cs @@ -0,0 +1,26 @@ +using FruitBank.Common.Entities; +using LinqToDB; +using Mango.Nop.Data.Repositories; +using Nop.Core.Caching; +using Nop.Core.Configuration; +using Nop.Core.Events; +using Nop.Data; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; + +public class EkaerHistoryMappingDbTable : MgDbTableBase +{ + public EkaerHistoryMappingDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings) + : base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings) + { + } + + // loadRelations → a szülő EkaerHistory is töltődik (a junction → deklaráció navigáció). + public IQueryable GetAll(bool loadRelations) => loadRelations ? base.GetAll().LoadWith(m => m.EkaerHistory) : base.GetAll(); + + // Egy EKÁER-deklaráció forrás-rekordjai (bejövő: N ShippingDocument; kimenő: 1 Order). + public IQueryable GetByEkaerHistoryId(int ekaerHistoryId, bool loadRelations) => GetAll(loadRelations).Where(m => m.EkaerHistoryId == ekaerHistoryId); + + // Egy forrás-rekordhoz (ShippingDocument/Order Id) tartozó mapping sor(ok) — a dedup-hoz / a sorhoz tartozó EKÁER kereséséhez. + public IQueryable GetByForeignKey(int foreignKey, bool loadRelations) => GetAll(loadRelations).Where(m => m.ForeignKey == foreignKey); +} diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs index dec7e6a..ad4ae82 100644 --- a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs @@ -55,6 +55,9 @@ public class FruitBankDbContext : MgDbContextBase, public OrderItemDtoDbTable OrderItemDtos { get; set; } public PartnerDbTable Partners { get; set; } + public PartnerDepotDbTable PartnerDepots { get; set; } + public EkaerHistoryDbTable EkaerHistories { get; set; } + public EkaerHistoryMappingDbTable EkaerHistoryMappings { get; set; } public CargoPartnerDbTable CargoPartners { get; set; } public CargoTruckDbTable CargoTrucks{ get; set; } @@ -86,7 +89,7 @@ public class FruitBankDbContext : MgDbContextBase, public IRepository StockQuantityHistoriesExt { get; set; } public FruitBankDbContext(INopDataProvider dataProvider, ILockService lockService, FruitBankAttributeService fruitBankAttributeService, IStoreContext storeContext, - CargoPartnerDbTable cargoPartnerDbTable, CargoTruckDbTable cargoTruckDbTable, PartnerDbTable partnerDbTable, ShippingDbTable shippingDbTable, ShippingDocumentDbTable shippingDocumentDbTable, ShippingItemDbTable shippingItemDbTable, + CargoPartnerDbTable cargoPartnerDbTable, CargoTruckDbTable cargoTruckDbTable, PartnerDbTable partnerDbTable, PartnerDepotDbTable partnerDepotDbTable, EkaerHistoryDbTable ekaerHistoryDbTable, EkaerHistoryMappingDbTable ekaerHistoryMappingDbTable, ShippingDbTable shippingDbTable, ShippingDocumentDbTable shippingDocumentDbTable, ShippingItemDbTable shippingItemDbTable, ShippingItemPalletDbTable shippingItemPalletDbTable, FilesDbTable filesDbTable, ShippingDocumentToFilesDbTable shippingDocumentToFilesDbTable, ProductDtoDbTable productDtoDbTable, OrderDtoDbTable orderDtoDbTable, OrderItemDtoDbTable orderItemDtoDbTable, OrderItemPalletDbTable orderItemPalletDbTable, StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos, CustomerCreditDbTable customerCreditDbTable, @@ -115,6 +118,9 @@ public class FruitBankDbContext : MgDbContextBase, Files = filesDbTable; Partners = partnerDbTable; + PartnerDepots = partnerDepotDbTable; + EkaerHistories = ekaerHistoryDbTable; + EkaerHistoryMappings = ekaerHistoryMappingDbTable; CargoPartners = cargoPartnerDbTable; CargoTrucks = cargoTruckDbTable; @@ -775,6 +781,25 @@ public class FruitBankDbContext : MgDbContextBase, return list; } + /// EKÁER-deklaráció (EkaerHistory) + a hozzá tartozó forrás-mapping sorok ATOMIKUS létrehozása. + /// A mapping tartja a kapcsolatot a forrás-rekordokhoz (bejövő: N ShippingDocument egy (Shipping, Partner, + /// PartnerDepot) csoportból; kimenő: 1 Order) — így a deklaráció a rendszer-tábláktól FÜGGETLENÜL törölhető + /// és újragenerálható. Az InsertAsync az identity Id-t visszaírja, így a mapping sorok már a friss Id-ra mutatnak. + public async Task AddEkaerHistoryWithMappingsAsync(EkaerHistory ekaerHistory, IEnumerable foreignKeys) + { + ArgumentNullException.ThrowIfNull(ekaerHistory); + + await TransactionSafeAsync(async _ => + { + await EkaerHistories.InsertAsync(ekaerHistory); + foreach (var foreignKey in foreignKeys) + await EkaerHistoryMappings.InsertAsync(new EkaerHistoryMapping { EkaerHistoryId = ekaerHistory.Id, ForeignKey = foreignKey }); + return true; + }); + + return ekaerHistory; + } + public async Task AddCustomerAddressMappingAsync(int customerId, int addressId) { var customerAddressMapping = new CustomerAddressMapping diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/PartnerDbTable.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/PartnerDbTable.cs index 15444e4..b768bc1 100644 --- a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/PartnerDbTable.cs +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/PartnerDbTable.cs @@ -22,9 +22,10 @@ public class PartnerDbTable : MgDbTableBase { return loadRelations ? GetAll() - .LoadWith(sd => sd.ShippingDocuments).ThenLoad(s => s.Shipping) - .LoadWith(sd => sd.ShippingDocuments).ThenLoad(si => si.ShippingItems).ThenLoad(sip => sip.ShippingItemPallets) - .LoadWith(sd => sd.ShippingDocuments).ThenLoad(sdtof => sdtof.ShippingDocumentToFiles).ThenLoad(f => f.ShippingDocumentFile) + .LoadWith(p => p.PartnerDepots) + .LoadWith(p => p.ShippingDocuments).ThenLoad(sd => sd.Shipping) + .LoadWith(p => p.ShippingDocuments).ThenLoad(sd => sd.ShippingItems).ThenLoad(si => si.ShippingItemPallets) + .LoadWith(p => p.ShippingDocuments).ThenLoad(sd => sd.ShippingDocumentToFiles).ThenLoad(sdtof => sdtof.ShippingDocumentFile) : GetAll(); } diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/PartnerDepotDbTable.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/PartnerDepotDbTable.cs new file mode 100644 index 0000000..83d0c63 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/PartnerDepotDbTable.cs @@ -0,0 +1,27 @@ +using FruitBank.Common.Entities; +using LinqToDB; +using Mango.Nop.Data.Repositories; +using Nop.Core.Caching; +using Nop.Core.Configuration; +using Nop.Core.Events; +using Nop.Data; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; + +public class PartnerDepotDbTable : MgDbTableBase +{ + public PartnerDepotDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings) + : base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings) + { + } + + public override IOrderedQueryable GetAll() => base.GetAll().OrderBy(p => p.PartnerId); + + public IQueryable GetAll(bool loadRelations) + { + return loadRelations ? GetAll().LoadWith(sd => sd.Partner) : GetAll(); + } + + public Task GetByIdAsync(int id, bool loadRelations) => GetAll(loadRelations).FirstOrDefaultAsync(p => p.Id == id); + public IQueryable GetByPartnerId(int partnerId, bool loadRelations) => GetAll(loadRelations).Where(p => p.PartnerId == partnerId); +} diff --git a/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs b/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs index 0a356bc..d9f67ea 100644 --- a/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs +++ b/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs @@ -21,6 +21,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.DependencyInjection.Extensions; +using Nop.Core; using Nop.Core.Domain.Orders; using Nop.Core.Infrastructure; using Nop.Data; @@ -43,6 +44,7 @@ using System.Net.Http.Headers; using Mango.Nop.Core.Loggers; using AyCode.Services.Nav; using AyCode.Services.Nav.Ekaer; +using AyCode.Services.Nav.Ekaer.Models; using FruitBank.Common.Services.Ekaer; using FruitBank.Common.Server.Services.Ekaer; using System.Security.Authentication; @@ -83,6 +85,9 @@ public class PluginNopStartup : INopStartup services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -198,18 +203,44 @@ public class PluginNopStartup : INopStartup BaseUrl = c["BaseUrl"] ?? "https://import-test-b.ekaer.nav.gov.hu", // TEST; PROD: https://import.ekaer.nav.gov.hu }; }); - services.AddSingleton(sp => + + // EKÁER konfiguráció (cégadat + telephely + küszöb + árfolyam), configból (appsettings "Ekaer"). + // IEkaerSettings-ként kötjük be (AyCode-kontraktus) — a fogyasztók az interfészt kérik, mint az INavCredentials-t. + services.AddSingleton(sp => { - var c = sp.GetRequiredService().GetSection("Ekaer:Company"); - return new EkaerCompanyInfo + var c = sp.GetRequiredService().GetSection("Ekaer"); + var co = c.GetSection("Company"); + var site = co.GetSection("Site"); + return new EkaerSettings { - Name = c["Name"], - TaxId = c["TaxId"], - CountryCode = c["CountryCode"] ?? "HU", // a NAV EKÁER magyar - PostalCode = c["PostalCode"], - City = c["City"], - Street = c["Street"], - // UnloadLocation (saját telephely): magyar címnél a NAV Name/VatNumber/Phone/Email-t is kér — TODO configból összeállítani. + Company = new EkaerCompanyInfo + { + Name = co["Name"], + TaxId = co["TaxId"], + CountryCode = co["CountryCode"] ?? "HU", // a NAV EKÁER magyar + PostalCode = co["PostalCode"], + City = co["City"], + Street = co["Street"], + // Telephely = fizikai fel-/lerakodási hely (bejövőnél lerakodás, kimenőnél felrakodás). + // Magyar címnél a NAV Name/VatNumber/Phone/Email-t is megköveteli. Ha = székhely, ugyanaz a cím. + Site = new LocationType + { + Name = co["Name"], + VatNumber = co["TaxId"], + Country = co["CountryCode"] ?? "HU", + ZipCode = site["PostalCode"], + City = site["City"], + Street = site["Street"], + Phone = co["Phone"], + Email = co["Email"], + }, + }, + + // Default ÉRTÉK szándékosan nincs: be nem töltött config → 0. A 0 árfolyam a számításkor hibát dob, + // a 0 küszöb „mindent jelentünk" — egyik sem számol elavult, kódba égetett értékkel. + EurHufRate = c.GetValue("ExchangeRate:EurHuf"), + ThresholdWeightKg = c.GetValue("Thresholds:WeightKg"), + ThresholdValueHuf = c.GetValue("Thresholds:ValueHuf"), }; }); @@ -237,8 +268,10 @@ public class PluginNopStartup : INopStartup }) .AddAcBinaryProtocol(opts => { + //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 from DI. diff --git a/Nop.Plugin.Misc.AIPlugin/Mapping/NameCompatibility.cs b/Nop.Plugin.Misc.AIPlugin/Mapping/NameCompatibility.cs index ee7dc2f..7a11378 100644 --- a/Nop.Plugin.Misc.AIPlugin/Mapping/NameCompatibility.cs +++ b/Nop.Plugin.Misc.AIPlugin/Mapping/NameCompatibility.cs @@ -49,6 +49,10 @@ public partial class NameCompatibility : INameCompatibility { typeof(CargoPartner), FruitBankConstClient.CargoPartnerDbTableName}, { typeof(CargoTruck), FruitBankConstClient.CargoTruckDbTableName}, + + { typeof(PartnerDepot), FruitBankConstClient.PartnerDepotDbTableName}, + { typeof(EkaerHistory), FruitBankConstClient.EkaerHistoryDbTableName}, + { typeof(EkaerHistoryMapping), FruitBankConstClient.EkaerHistoryMappingDbTableName}, }; diff --git a/Nop.Plugin.Misc.AIPlugin/docs/EKAER/EKAER_ISSUES.md b/Nop.Plugin.Misc.AIPlugin/docs/EKAER/EKAER_ISSUES.md index 760715e..f4ee6b5 100644 --- a/Nop.Plugin.Misc.AIPlugin/docs/EKAER/EKAER_ISSUES.md +++ b/Nop.Plugin.Misc.AIPlugin/docs/EKAER/EKAER_ISSUES.md @@ -9,10 +9,9 @@ Scope: a FruitBank EKÁER-bejelentés szerver-oldali / nopCommerce-integrációs ## MGFBANKPLUG-EKAER-I-T3X8: A `Product.Gtin` átmenetileg a VTSZ-t tárolja — szétválasztandó -**Status:** Closed (2026-06-02) — superseded · **Priority:** P3 · **Type:** I (adatmodell / átmeneti megoldás) +**Status:** Open · **Priority:** P3 · **Type:** I (adatmodell / átmeneti megoldás) -### Resolution -**Áthelyezve** az általános adatmodell-topicba: **`FBANKAPP-DMODEL-I-P6X4`** (`FruitBank.Common/docs/DATAMODEL/DATAMODEL_ISSUES.md#fbankapp-dmodel-i-p6x4`). Indok: a GTIN/VTSZ szétválasztás **általános** adatmodell-kérdés, nem EKÁER-specifikus — az EKÁER csak felhasználója. A követés ott folytatódik; az alábbi leírás referencia. +> **Újranyitva (2026-06-15):** a probléma **továbbra is fennáll** — a `Gtin` jelenleg is a VTSZ-t tárolja, a szétválasztás nem történt meg. A 2026-06-02-i `FBANKAPP-DMODEL-I-P6X4`-be (DATAMODEL) helyezés visszavonva; a követés ITT (EKÁER) folytatódik. *(A `FBANKAPP-DMODEL-I-P6X4` ennek megfelelően lezárva — DATAMODEL.)* 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). @@ -27,3 +26,18 @@ Egy termékhez a kettő nem azonos; a `Gtin` oszlop VTSZ-ként való használata **Affected:** - `FruitBank.Common/Dtos/ProductDto.cs` → `Gtin` property (a `[Column(nameof(Product.Gtin))]` jelöléssel, summary-ban megjelölve) - jövőbeli: `ShippingToEkaerMapper` (`FruitBank.Common/Services/Ekaer/`) — a `productVtsz`-t a külön `Vtsz` mezőből olvassa + +## MGFBANKPLUG-EKAER-I-M6Y2: A számla-pénznem a Partnerből jön — jobb lenne a ShippingDocument-en + +**Status:** Open · **Priority:** P3 · **Type:** I (adatmodell) + +Az EKÁER tétel-érték HUF-ra számításához a forrás-pénznem jelenleg a **`Partner.Currency`**-ből jön (a beszállító alap-devizája). **Most 100%-ban megbízható** — a partner egyértelműen meghatározza a devizát. + +**Hatás:** működik; de a pénznem **konceptuálisan a szállítmányhoz / dokumentumhoz tartozik**, nem a partnerhez (egy partner elvileg többféle devizában is számlázhat). + +**Javítási irány:** külön **`Currency` oszlop a `ShippingDocument`-en**, és az `EkaerValueCalculator` / `ShippingToEkaerMapper` onnan olvasson; a `Partner.Currency` maradjon fallback. **Nem prioritás** — a jelenlegi partner-alapú forrás elegendő. + +**Affected:** +- `FruitBank.Common/Entities/ShippingDocument.cs` — jövőbeli `Currency` oszlop +- `FruitBank.Common/Services/Ekaer/EkaerValueCalculator.cs` + `ShippingToEkaerMapper.cs` — a deviza-forrás +- jelenlegi forrás: `PartnerBase.Currency` diff --git a/Nop.Plugin.Misc.AIPlugin/docs/EKAER/EKAER_TODO.md b/Nop.Plugin.Misc.AIPlugin/docs/EKAER/EKAER_TODO.md index e78a6ca..81502de 100644 --- a/Nop.Plugin.Misc.AIPlugin/docs/EKAER/EKAER_TODO.md +++ b/Nop.Plugin.Misc.AIPlugin/docs/EKAER/EKAER_TODO.md @@ -15,19 +15,84 @@ A `ShippingToEkaerMapper` megírása előtt tisztázandó pontok. Minden sor a j | # | Téma | Jelenlegi feltételezés | Nyitott kérdés | |---|------|------------------------|----------------| -| 1 | `tradeType` (E/I/D irány) | Bejövő = beszerzés. HU feladó → **D** (belföldi), egyébként → **I** (import). A feladó országát a `ShippingDocument.Partner` / `Country` adja. | Automatikusan az országból dőljön el, vagy kézzel (UI) állítható? A kimenő (**E**, export) egyelőre hatókörön kívül (lásd #7). | -| 2 | Célhely (destination) | Hardcode-olt FruitBank-telephely (cím). | Ne hardcode legyen — konfigból/beállításból jöjjön. Honnan (plugin settings / nopCommerce warehouse)? | -| 3 | Érték (`value`, HUF) | `UnitPriceOnDocument × MeasuredQuantity`. | A bejövő tételek tipikusan **EUR**-ban — deviza + árfolyam (FX) konverzió kell HUF-ra. Árfolyam forrása/időpontja? Vevőnként egyeztetni. | +| 1 | `tradeType` (E/I/D irány) | ✅ **Frissítve (2026-06):** a feladó és a címzett **országkódjának összevetése** dönt: egyezik → **D** (belföld); eltér → kimenőnél **E** (export), bejövőnél **I** (import). (Korábban: HU ↔ nem-HU.) | A `Customer.CountryId` → ISO-feloldás kimenőnél még TODO (#7) → addig a vevő HU, ezért a kimenő ténylegesen D marad. | +| 2 | Célhely / telephely | ✅ **Megoldva:** configból (`Ekaer:Company` + `Site` al-szekció) → `IEkaerSettings.Company` + `EkaerCompanyInfo.Site` (`LocationType`, fel-/lerakodás). Nincs hardcode. | Több telephely (mint a `PartnerDepot`) — későbbi, ha kell. | +| 3 | Érték (`value`, HUF) | ✅ **Megoldva:** `UnitPriceOnDocument × QuantityOnDocument × árfolyam` (`EkaerValueCalculator`). Pénznem a `Partner.Currency`-ből; EUR-HUF árfolyam configból (`Ekaer:ExchangeRate:EurHuf`, MNB középárfolyam). | Árfolyam dinamizálása (jelenleg „drót" config-érték) — későbbi. | | 4 | Tömeg (`weight`) | `ShippingItem.MeasuredGrossWeight` (bruttó). | Az EKÁER **bruttó** tömeget vár — megerősíteni, hogy ez bruttó és nem nettó. | | 5 | Granularitás | 1 `ShippingDocument` → 1 `tradeCard`; 1 `Shipping` → több művelet (több dokumentum). | Megerősíteni: tényleg dokumentumonként egy tradeCard? | | 6 | Eladó (`seller*`) mezők | `ShippingDocument.Partner`-ből: `Name`, `TaxId` (első 8 jegy = adószám-törzs), `CountryCode`, `City`, `Street`, `PostalCode`. | A `Partner` (`PartnerBase`) pontos mezőnevei/forrásai — megerősíteni. | -| 7 | Kimenő irány (`Order` → EKÁER) | Egyelőre nincs. A vevő gyakran maga viszi el (saját fuvar) → nem mi jelentünk. | Mikor kell mégis nekünk export/kimenő (E) tradeCard-ot küldeni? | -| 8 | `productVtsz` forrás | `ProductDto.Gtin` (átmeneti). | GTIN ≠ VTSZ — lásd [`MGFBANKPLUG-EKAER-I-T3X8`](EKAER_ISSUES.md). Hosszú távon külön `Vtsz` mező. | +| 7 | Kimenő irány (`Order` → EKÁER) | ✅ **Implementálva** (`ToConsignment(OrderDto)`): mi=eladó, vevő=címzett; tömeg `OrderItemDto.GrossWeight`, érték nettó (UnitPriceExclTax × qty), jelenleg minden HUF (rate 1). **`carrierText` kimenőnél NEM töltve** (üres) — a vevő maga viszi el, ő már a címzett; a carrierText opcionális (2026-06). | **Nyitott:** a vonó jármű (**rendszám**) forrása — a `Customer`-hez még nincs bekötve (üres → warning, a felrakodás megkezdéséig pótolandó). Külföldi deviza + `Customer.CountryId` ISO-feloldás (export `E`): későbbi. | +| 8 | `productVtsz` forrás | `ProductDto.Gtin` (átmeneti). | GTIN ≠ VTSZ — lásd [`MGFBANKPLUG-EKAER-I-T3X8`](EKAER_ISSUES.md); hosszú távon külön `Vtsz` mező. A VTSZ-**csoportosítás** + `productName` **külön** kérdés: **T-V9G3**. | | 9 | tétel `tradeReason` | `A` (beszerzés) — a mapper ezt használja bejövő árura. | Korábban tévesen `S`-nek feltételezve; az enum valójában `S`=értékesítés, `A`=beszerzés. Üzletileg megerősítendő. | -| 10 | `value` mező | Egyelőre **üresen hagyva** (opcionális mező) a deviza tisztázásáig (#3). | Megerősíteni, hogy a NAV elfogadja érték nélkül bejövő relációban, vagy kötelező-e. | +| 10 | `value` mező | ✅ **Megoldva:** a value most töltve (lásd #3); 0 / ismeretlen ár esetén `null` marad. | A NAV 2021-től `value > 0`-t vár — a validátorban a `value > 0` üzleti szabály még hozzáadandó. | **Affected:** - `ShippingToEkaerMapper` (`FruitBank.Common/Services/Ekaer/`) — a leképezés - `EkaerTradeCardValidator` / `EkaerSubmitService` / `EkaerManageService` (`AyCode.Services/Nav/Ekaer/`) — validáció + beküldés + HTTP (általános NAV-réteg) - `FruitBankEkaerService` (`FruitBank.Common.Server/Services/Ekaer/`) — a szerver-oldali fogyasztó (map → submit) - Forrás-entitások (`FruitBank.Common`): `Shipping`, `ShippingDocument`, `ShippingItem`, `ProductDto.Gtin` + +## MGFBANKPLUG-EKAER-T-W3R8: EKÁER küszöb-kategória a terméknél (enum, DB-ben tárolva) + +**Status:** Open · **Priority:** P2 · **Type:** T (adatmodell + mapping) + +Az EKÁER bejelentés-kötelezettség küszöbe **termékfüggő** — a VTSZ kockázati besorolása dönti el, melyik értékhatár vonatkozik rá: + +| Kategória | Tömeg | Érték (nettó) | +|---|---|---| +| Kockázatos élelmiszer | 200 kg | 250 000 Ft | +| Kockázatos egyéb (nem élelmiszer) | 500 kg | 1 000 000 Ft | +| Nem kockázatos | 2500 kg | 5 000 000 Ft | + +> **A fenti értékek a 13/2020. (XII. 23.) PM rendelet kategóriái — REFERENCIA, 2026-06 állapot.** Az alkalmazott küszöb a kódba **NINCS drótozva** (`appsettings` → `Ekaer:Thresholds`), és a jogszabály/beállítás változásával módosul; ezt a táblát csak tájékoztatásul, **dátumozva** tartjuk. +> +> ⚠️ **A túljelentés is bírság** (megerősítve, 2026-06): a globális „legszigorúbb" küszöb egy **nem-élelmiszer** terméket (ami csak a magasabb határnál kötelező) **túljelentene**. Fruit-importőrnél (minden áru kockázatos élelmiszer) ez nem gond — de amint nem-élelmiszer is bekerül, ez a per-termék kategória **szükségessé** válik (nem csak P2). + +**Jelenlegi (átmeneti):** a `CreateMissingEkaerHistories` egyetlen, globális küszöböt használ az `EkaerSettings`-ből — **configból** (`Ekaer:Thresholds`), a kódba **NEM drótozva**. Jelenleg a **kockázatos-élelmiszer** határ van beállítva (a fruit-importőr profil miatt, a legszigorúbb a beszerzéseknél). A kapu a küszöböt `(Shipping, Partner)`-re **aggregálva** nézi, külföldinél küszöb nélkül kötelez (lásd README → kapu-szakasz). + +**Cél:** a **terméknél (Product) kézzel megadható** küszöb-**KATEGÓRIA** (nem a konkrét érték!) — egy **enum** (pl. `EkaerThresholdCategory { RiskyFood, RiskyOther, NonRisky }`) —, **DB-ben tárolva** (Product oszlop / GenericAttribute). A konkrét kg/Ft értékeket a **kód** rendeli a kategóriához → értékhatár-változáskor egy helyen módosul. A küszöb-kapu dokumentumonként a tételek kategóriái szerint alkalmazza a megfelelő határt. (Későbbi lépés: a kategória automatikus levezetése a NAV kockázatos-VTSZ-listákból.) + +**Affected:** +- `FruitBank.Common/Dtos/ProductDto.cs` — küszöb-kategória mező (enum, DB-ben) +- `EkaerSettings` / `EkaerValueCalculator` (`FruitBank.Common/Services/Ekaer/`) — kategória → küszöbérték leképezés +- `FruitBankDataController.CreateMissingEkaerHistories` — a kapu a tételek kategóriái szerint + +## MGFBANKPLUG-EKAER-T-V9G3: tradeCardItem-ek csoportosítása VTSZ szerint + `productName` Category-ből + +**Status:** Open · **Priority:** P2 · **Type:** T (mapping) · **2026-06** + +A NAV felé **egy VTSZ-re egy `tradeCardItem`** kell — több azonos-VTSZ-ű termék (pl. kajszibarack/nektarin/őszibarack, mind `08093000`) **egy** tételbe vonva, **összegzett** tömeggel/értékkel. Jelenleg a mapper terméktételenként ad egy `tradeCardItem`-et → azonos VTSZ-nél több sor (a NAV felé nem jó). A nehézség a **`productName`** (a csoport tagjainak más a neve). + +> **Ez NEM a VTSZ/GTIN-szétválasztás** — az **külön** nyitott kérdés: [`MGFBANKPLUG-EKAER-I-T3X8`](EKAER_ISSUES.md) (lásd #8). A T-V9G3 **csak a csoportosítást és a csoport-nevet** oldja meg, a VTSZ tárolási helyétől (jelenleg `Product.Gtin`) **függetlenül**. + +**Eldöntött terv (2026-06):** +- **`productName` (a csoport neve):** egy nopCommerce **`Category`**-ből, ami VTSZ→név **helper**: a `Category.MetaKeywords` első 8 karaktere = a VTSZ, a `Category.Name` = a `productName`. A kategóriát a **termék VTSZ-e** (jelenleg a `Gtin`-ben — I-T3X8) **= `MetaKeywords`[0..8]** match azonosítja → a termék kategória-**tagsága lényegtelen** (több kategóriából csak az egyező számít). A kategóriák a weben is pontosak maradnak. +- **`itemExternalId`:** **sorszám** (1, 2, …) a VTSZ-csoportok fölött — a séma szerint „bejelentésen belüli **tetszőleges** azonosító a **kérés↔válasz** párosításhoz" (max 50), NEM kell stabilnak / forrás-Id-nek lennie; int-parse-olható. +- **Csoportosítás helye:** `BuildTradeCard` (VTSZ szerint group → Σtömeg, Σérték, név a lookupból). A **kapu** (`EvaluateObligation`) NEM érintett (csak Σ-t számol → nincs plusz query a reconciliation-nél). +- **Név-feloldás:** **csak generáláskor**, szerver-oldalon — a `ProductDto`-t NEM szennyezzük EKÁER-only mezővel. A termék-query `(productId, categoryId)` tuple-t ad (a VTSZ↔`MetaKeywords` joinból), majd egy batch `Category`-load `ToDictionary`-be → `productId → categoryName`. +- **Fallback:** ha egy VTSZ-hez nincs kategória → a `productName` üres marad → a **generate-validátor** jelzi (mint a `value > 0`). + +**Üzemeltetési feltétel:** a VTSZ-forrás (jelenleg `Gtin`) és a kategória `MetaKeywords`-VTSZ-e legyen **szinkronban** (FruitBank-felelősség; a beírásnál a VTSZ-t elöl). + +**Affected:** +- `FruitBank.Common/Services/Ekaer/ShippingToEkaerMapper.cs` — `BuildTradeCard` VTSZ-group + `vtszNames` lookup +- `FruitBank.Common.Server/Services/Ekaer/{IFruitBankEkaerService,FruitBankEkaerService}.cs` — a `vtszNames` átadása a generate-en +- `FruitBankDataController.GenerateEkaerXmlDocument` (plugin) — `(productId, categoryId)` tuple + batch `Category`-load → `Dictionary` +- nopCommerce `Category` (MetaKeywords = VTSZ, Name = productName) — adat/üzemeltetés + +## MGFBANKPLUG-EKAER-T-D8R4: Kapu-csoportosítás (Shipping, PartnerDepot)-re; a PartnerDepotId kötelező + +**Status:** Open · **Priority:** P2 · **Type:** T (kapu) · **2026-06** + +A `CreateMissingEkaerHistories` bejövő kapuja jelenleg `(ShippingId, PartnerId, PartnerDepotId)`-re csoportosít. Mivel a **`PartnerDepot` egyértelműen egy `Partner`-hez tartozik** (a depó implikálja a feladót), a `PartnerId` a kulcsban **redundáns** → elég a `(ShippingId, PartnerDepotId)` csoportosítás. + +**Cél:** +- A csoportosítási kulcs `(ShippingId, PartnerDepotId)` (a `PartnerId` kivehető). +- A **`PartnerDepotId` kötelező** az EKÁER-hez: ha egy szállítólevélen **nincs** `PartnerDepotId` → **hiba**, és **NE generálódjon** rá deklaráció. A kapu a hiányzó-telephelyű dokumentumokat kihagyja + üzenettel jelzi (mint a `DataError`-nál); a generate `ValidationError`-t ad (nem küldhető), nem üres/téves tradeCard-ot. + +**Megj.:** szigorítás a jelenlegihez képest, ahol a `null` `PartnerDepotId` még külön (null-telephelyű) csoportként kezelődik. Bevezetés előtt a meglévő szállítólevelek `PartnerDepotId`-ját pótolni kell, különben a kapu kihagyja őket. + +**Affected:** +- `FruitBankDataController.CreateMissingEkaerHistories` (plugin) — a `GroupBy` kulcs `(ShippingId, PartnerDepotId)`-re + a hiányzó-`PartnerDepotId` hiba-ág (kihagyás + üzenet) +- `FruitBankDataController.GenerateEkaerXmlDocument` / `FruitBankEkaerService` — hiányzó telephelynél `ValidationError`, nincs generálás +- `ShippingToEkaerMapper` / `EkaerReportability` (`FruitBank.Common/Services/Ekaer/`) — ha a feladó-cím a `PartnerDepot`-ból jön, a hiányzó telephely validációs hiba diff --git a/Nop.Plugin.Misc.AIPlugin/docs/EKAER/README.md b/Nop.Plugin.Misc.AIPlugin/docs/EKAER/README.md index 4f8b81b..52dbfaf 100644 --- a/Nop.Plugin.Misc.AIPlugin/docs/EKAER/README.md +++ b/Nop.Plugin.Misc.AIPlugin/docs/EKAER/README.md @@ -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 még le nem fedett szállítólevelekre/rendelésekre `Pending` `EkaerHistory` sorokat hoz létre, **csak ha kötelező** (lásd a kapu-szakaszt lentebb). Bejövőnél a `(Shipping, Partner, PartnerDepot)` csoport **EGY** `EkaerHistory`-t kap, a csoport dokumentumai pedig `EkaerHistoryMapping` sorokként kapcsolódnak; kimenőnél egy rendelés = egy `EkaerHistory` + egy mapping. `EkaerCreateResult`-ot ad vissza (létrehozott szám + a kihagyott tételek üzenetei). +- **`GenerateEkaerXmlDocument(ekaerHistoryId)`** — egy `EkaerHistory`-deklarációra (a mappingben rögzített forrás-dokumentumok ÖSSZES tételét egy tradeCard-ba vonva) 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,15 +40,32 @@ 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` (szállítmányozó — **opcionális**, NAV nem követeli) | **bejövő:** `Shipping.CargoPartner.Name` (valós, külön fuvarozó); **kimenő:** — **üres** (a vevő maga viszi el, ő már a címzett; nincs külön fuvarozónk) | | `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` | +| tétel `productName` | `ShippingItem.ProductName` (a VTSZ-szerinti csoportosítás + a csoport-név `Category`-ből: TODO **T-V9G3**) | | tétel `weight` | `ShippingItem.MeasuredGrossWeight` (bruttó) | | tétel `tradeReason` | `A` (beszerzés — bejövő áru). ⚠️ Az enum: `S`=értékesítés, `A`=beszerzés, `W`=bérmunka, `O`=egyéb. | 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; a forrás-rekordok az **`EkaerHistoryMapping`** junctionön át kapcsolódnak — bejövő: a `(Shipping, Partner, PartnerDepot)` csoport N `ShippingDocument`-je, kimenő: 1 `Order` —, az `EkaerHistory` maga **nem** hordoz forrás-kulcsot, az irány az `IsOutgoing`; a `Status` int-oszlop + `[NotColumn]` enum-nézet; a mapping a `GetAll(loadRelations: true)`-zal töltődik a `Mappings` asszociációba): +`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, PartnerDepot)` csoport (= feladó+telephely→címzett→jármű) — a tömeg/érték a csoport **ÖSSZES** dokumentumára **aggregálva** (13/2020 PM rendelet), és a csoport EGY `EkaerHistory`-t kap; 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(/* NAV TLS 1.2 */); services.AddScoped(); services.AddScoped(); services.AddScoped(); -services.AddSingleton(/* EkaerMappingOptions: destination + saját raktár */); +services.AddSingleton(/* EkaerSettings: Ekaer:Company + Site + küszöbök + EUR-HUF árfolyam — configból */); services.AddScoped(); ```