8 Commits
Author SHA1 Message Date
Adam e916f091b4 merge 2026-09-03 17:21:04 +02:00
Adam f0bde4b51a A termék-lista gridje rendezhető és szűrhető lett
Alapból csak az aktív termékek jönnek: a katalógusban ~600 termék van, ebből egyszerre ~70 él,
a többi archív. A nop kereső-paneljének Publikált legördülője eddig „Mind"-en állt — most csak
publikált az alapérték, és a legördülőn bármikor átkapcsolható.

- Ordering = true (eddig ki volt kommentelve) és ServerSide = false. A kliens-oldali mód nem
  kényelmi döntés: a saját oszlopok (tára, átlagsúly, mérendő, kamionon, elérhető) a lapozott
  találatra UTÓLAG kerülnek rá a ProductDto-ból, tehát nincsenek a SQL-ben — szerver-oldalon
  csak oldalanként lehetne rendezni őket, ami helyesnek látszik és téved
- a ProductList emiatt nem lapoz: a BaseSearchModel alapértéke 10 sor, és a DataTables
  kliens-módban nem küld lapozást — beavatkozás nélkül 10 sort hinne az egésznek
- ha a lista mégis elérné a felső korlátot, a grid kiírja; enélkül a rendezés és a szűrés némán
  a katalógus egy részén dolgozna
- gyorsszűrő sor: Név, SKU (szöveg), Elérhető (van/nincs), Mérendő?, Publikált
- alapból 100 sor/oldal, a menüben is szerepel

Két csapda a nop grid körül, mindkettő beírva a kódba:
- scrollX mellett a DataTables KLÓNOZZA a fejlécet, és a jQuery kezelők a klónra nem másolódnak
  át. Ezért a sor a törzs-tábla fejlécébe épül (azt klónozza újra minden rajzoláskor), az
  események pedig delegáltak, data-fb-col alapján
- a kereséshez a DataTables kiszedi a HTML-t a cellákból, tehát a nop ikonra renderelt logikai
  oszlopokra a kirenderelt osztálynévvel keresni SOSEM talál — üres eredményt ad, ami hibátlan
  szűrőnek látszik. Minden nem-szöveges szűrő ezért a nyers sor-objektumot nézi
2026-09-01 14:00:20 +02:00
Adam 409b0eaadc Az admin-adta csoport-foglaltság szerver-oldala, és az egységár védelme
Két, egymástól független változás — a kiolvasás-ellenőrző nézete mindkettőt érinti, ezért egy commit.

1) ADR FBANKAPP 0006-K4T9 szerver-oldal
- ShippingItemPalletGroupDbTable + DbContext + DI + NameCompatibility (e nélkül a LinqToDB az
  osztálynevet keresné táblaként)
- a tervező-modell a csoportokat EGYBEN hozza le, nem soronként; a rakomány-sorok viszik a csoport
  sorszámát és admin-adta raklapszámát
- az AutoPlanShipments FFD-je és a drop-ellenőrzés a PalletLoadLine bemenetre állt
- a testvér-keresés egyetlen FK-ra kulcsol: a "csoport hatóköre a dokumentum" szabályt maga a
  csoport sora hordozza; a hibaüzenet az admin sorszámát idézi, nem a surrogate azonosítót
- a kiolvasás-ellenőrzőn a mentés gomb fölött csoportonkénti mező, a beírt sorszámokból dinamikusan.
  Új bevitelnél üres és KÖTELEZŐ, mellette halványan a számított összeg — de nem validálunk ellene:
  a raklap korlátja a magasság, nem az alapterület, ezért a tételek összege nem alsó korlát

2) A termékcsere nem írja felül a dokumentum egységárát
A selectProduct eddig minden termékválasztásnál a termék eladási árát írta az egységárba, ár nélküli
terméknél nullát. Ez nem csak az árrést rontotta: az EKÁER ezt az értéket jelenti be a NAV felé
(UnitPriceOnDocument × QuantityOnDocument), és a product.ProductCost is ebből íródik. A ...OnDocument
mezők a szállítólevél nyers állításai — a termék-törzsadat nem érvényteleníti őket.

A raklapszám újraszámolása termékcserénél megmarad: az ADR 0004-M9F3 szerint az nem fagy be.
2026-09-01 11:31:51 +02:00
AdamandClaude Opus 5 4b2e6f9d34 Az ÁFA-kimutatás kimenő oldala a lezárt rendelésekből számol
A fogyasztás-sorok csak az árrés-funkció élesítése óta léteznek, ezért a riport
augusztusra 12 napot mutatott 31 helyett: 126,3 M-ot a 309,7 M helyett. A forrás
mostantól a rendeléslista, a hónap kulcsa az átvétel időpontja.

ADR 0004-K9T3, horgony: MGFBANKPLUG-INNV-T-R6D2 (7-ből 6 lépés kész).

- populáció: minden lezárt, nem törölt rendelés; a be nem küldött is, számlálóval
- a hónap a DateOfReceipt, invariáns kultúrával parse-olva; fallback nincs, a
  datálatlan rendelés anomália-sorra kerül
- három anomália-számláló a felületen, a bejövő oldal mintájára
- a DataAvailableFrom padló megszűnt: a rendeléslista odáig megy vissza, ameddig
  az adat
- az InnVoice attribútum-kulcsok konstansok lettek, mind a nyolc hívási helyen

Hosszú id-lista nem megy át az SQL-nek: a NOT IN a teljes rendelés-történettel
időtúllépésbe futott. A halmaz-műveletek memóriában futnak, a tétel-lekérdezés
1000-es darabokban megy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:15:14 +02:00
AdamandClaude Opus 5 9f1ef5db04 Az ÁFA-kimutatás kimenő áfája a bruttóból számol
A kimenő áfa eddig a PriceInclTax - PriceExclTax különbségből állt elő, ez a
kettő viszont a tételek 99,9%-án azonos: a nop adószolgáltatása ezekre a
termékekre változatlanul adja vissza az árat. Augusztusra 162 072 Ft-ot mutatott
26,8 millió helyett, és az egyenleg előjelét is megfordította.

- OutgoingVatRate const, egy helyen; a kimenő áfa a bruttóból, ezzel a kulccsal
- a nézet kiírja a kulcsot, mert a számból nem derül ki
- ADR 0004-K9T3: a kimenő oldal a lezárt rendelésekből, az átvétel hónapja
  szerint fog számolni (a mai forrás, a fogyasztás-sor, csak aug. 20-tól létezik,
  ezért a riport 12 napot mutatott 31 helyett)
- horgony-bejegyzés: MGFBANKPLUG-INNV-T-R6D2

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:05:23 +02:00
AdamandClaude Opus 5 6b7cd60ccf A plugin-beállítás mentése nem törli a titkokat
A jelszó-mező sosem rendereli vissza az értékét, tehát az érintetlen mező üresen
posztol vissza. A POST eddig ezt ráírta a beállításra, így egyetlen mentés
kinullázta az OpenAI, a Cerebras és a Z.ai kulcsot, valamint az InnVoice jelszót.

- KeepStoredWhenBlank: a titkot csak kitöltött mező írhatja felül
- autocomplete="new-password" minden titok-mezőn, mert a böngésző jelszókezelője
  az admin saját jelszavát töltötte beléjük
- a GET betölti az OpenAIApiBaseUrl-t is, amit eddig minden mentés kinullázott
- a ZaiService őre az üresre vizsgál, nem a null-ra, tehát ezentúl fog is

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:05:05 +02:00
Adam 9846b78d42 A tükröt másoló script, és az ütemezett szinkron elvetése
PROD-on a teljes InnVoice-lekérdezés a HttpClient 100 másodperces korlátjába futott, egyetlen sor
sem jött létre, a 30 naponta 10-es kvótából viszont fogyott. A tükör DEV-ből átmásolva.

- INNVOICE_incoming_copy_dev_to_prod.sql: két adatbázis közt viszi át mindkét táblát az
  azonosítókkal és a SourceUpdatedAt vízjellel együtt, üres cél-tükörre, egy tranzakcióban
- MGFBANKPLUG-INNV-T-P4H7: az első feltöltés túlélhetővé tétele — időkorlát, dátumtól induló
  betöltés az olcsóbb kvótán, a félrevezető hibaüzenet, a letöltés és a feldolgozás külön mérése
- MGFBANKPLUG-INNV-T-M6K3 lezárva, won't fix: az óránkénti ütemezés csak akkor verné a gombot, ha
  a felhasználó megbízik benne — de nem tudhatja, hogy a legutolsó számla bent van-e, tehát úgyis
  megnyomná, és az ütemezés ráadásként költene kvótát
2026-08-29 12:13:23 +02:00
Adam 684b8dbb3b A raklap-csoport együtt mozog akkor is, ha a mozgatott tétel raklapszáma egész
Az ADR 0004-M9F3 7. pontja „egy csoporttag áthelyezését" mondja, megkötés nélkül; a kód viszont
a MOZGATOTT tétel tört maradékára szűkítette a kaszkádot. Emiatt a viselkedés a művelet irányától
függött: a #1145 dokumentumon a mangót (0,114) felrakva a lime vele ment, a lime-ot (1,000)
felrakva a mangó a felrakón maradt.

- a kaszkád kiváltó feltétele a csoporttagság lett, felrakásnál, áthúzásnál és levételnél is
- a vitt oldal is tagságra szűr: a testvér forrás-kamionon lévő allocationjei jönnek, nem csak a
  törtet hordozók — különben egy egész raklapszámú csoporttárs ott maradt volna
- a "két kamionra nem kerülhet" őr szintén tagságra kulcsol: e nélkül egy teli raklap csoporttag
  némán másik kamionra kerülhetett, és a közös fizikai raklap kettévált
- GetRemainderGroupTruckAsync: ha a csoportban egyetlen törtet hordozó allocation sincs (csupa
  egész raklap), bármelyik kiosztás kijelöli a csoport kamionját

FBANKAPP-SHPLAN-B-X3V8 · előfeltétele az ADR 0006-K4T9-nek
2026-08-27 23:12:30 +02:00
32 changed files with 1144 additions and 184 deletions
@@ -277,7 +277,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
// ─────────────────────────────────────────────────────────────────
var todayRows = todaysOrders.Take(20).Select(o =>
{
var hasInnVoice = o.GenericAttributes.Any(a => a.Key == "InnVoiceOrderTableId");
var hasInnVoice = o.GenericAttributes.Any(a => a.Key == FruitBankPluginConst.InnVoiceOrderTableIdAttribute);
return new
{
id = o.Id,
@@ -299,7 +299,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
audited = todaysOrders.Count(o => o.IsAllOrderItemAudited),
missingInnVoice = todaysOrders.Count(o =>
o.IsAllOrderItemAudited &&
!o.GenericAttributes.Any(a => a.Key == "InnVoiceOrderTableId")),
!o.GenericAttributes.Any(a => a.Key == FruitBankPluginConst.InnVoiceOrderTableIdAttribute)),
completed = todaysOrders.Count(o => o.OrderStatus == OrderStatus.Complete),
rows = todayRows
};
@@ -313,7 +313,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
foreach (var o in todaysOrders.Where(o =>
o.IsAllOrderItemAudited &&
o.OrderStatus != OrderStatus.Complete &&
!o.GenericAttributes.Any(a => a.Key == "InnVoiceOrderTableId")))
!o.GenericAttributes.Any(a => a.Key == FruitBankPluginConst.InnVoiceOrderTableIdAttribute)))
{
alerts.Add(new
{
@@ -2221,7 +2221,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
{
var ga = o.GenericAttributes;
var dateOfReceipt = ga?.FirstOrDefault(a => a.Key == "DateOfReceipt")?.Value is string dv && DateTime.TryParse(dv, out var dp) ? dp : (DateTime?)null;
var innvoiceTechId = ga?.FirstOrDefault(a => a.Key == "InnVoiceOrderTechId")?.Value;
var innvoiceTechId = ga?.FirstOrDefault(a => a.Key == FruitBankPluginConst.InnVoiceOrderTechIdAttribute)?.Value;
var company = o.Customer != null
? $"{o.Customer.Company} {o.Customer.FirstName}_{o.Customer.LastName}".Trim()
: string.Empty;
@@ -880,11 +880,29 @@ public partial class CustomProductController : BaseAdminController
[CheckPermission(StandardPermission.Catalog.PRODUCTS_VIEW)]
public virtual async Task<IActionResult> List()
{
var model = await _productModelFactory.PrepareProductSearchModelAsync(new ProductSearchModel());
// Alapból csak az AKTÍV termékek. A katalógusban ~600 termék van, ebből egyszerre ~70 él; a többi
// archív, és a listát használhatatlanná tenné. A nop konvenciója: 0 = mind, 1 = csak publikált,
// 2 = csak nem publikált — a kereső-panel legördülőjén az admin bármikor átállíthatja.
var model = await _productModelFactory.PrepareProductSearchModelAsync(
new ProductSearchModel { SearchPublishedId = PublishedOnlySearchId });
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Product/List.cshtml", model);
}
/// <summary>A nop „csak publikált" szűrő-értéke a termék-kereső legördülőjében.</summary>
private const int PublishedOnlySearchId = 1;
/// <summary>
/// A kliens-oldali termék-grid felső sorkorlátja.
/// </summary>
/// <remarks>
/// A grid a böngészőben rendez és szűr (<c>List.cshtml</c> → <c>ServerSide = false</c>), ezért EGYBEN kell
/// megkapnia a találatokat. A <see cref="BaseSearchModel"/> alapértéke 10 sor, és a DataTables kliens-módban
/// nem küld lapozást — beavatkozás nélkül tehát 10 sort hinne az egésznek, a rendezés pedig némán csak azon
/// belül működne. A korlát bőséges a ~600-as katalógushoz; ha valaha túlnő rajta, a nézet ki is írja.
/// </remarks>
private const int ProductGridMaxRows = 5000;
[CheckPermission(StandardPermission.Catalog.PRODUCTS_VIEW)]
public virtual async Task<IActionResult> BulkEdit()
{
@@ -918,6 +936,10 @@ public partial class CustomProductController : BaseAdminController
[CheckPermission(StandardPermission.Catalog.PRODUCTS_VIEW)]
public virtual async Task<IActionResult> ProductList(ProductSearchModel searchModel)
{
// A kliens-oldali grid nem lapoz a szerveren — lásd ProductGridMaxRows.
searchModel.Start = 0;
searchModel.Length = ProductGridMaxRows;
//prepare model
var model = await _productModelFactory.PrepareProductListModelExtendedAsync(searchModel);
@@ -192,7 +192,8 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
averageWeight = item.ProductDto?.AverageWeight ?? 0d,
palletsOnDocument = item.PalletsOnDocument,
pallets = item.Pallets,
palletGroupNo = item.PalletGroupNo,
palletGroupNo = item.PalletGroup?.GroupNo,
palletGroupPallets = item.PalletGroup?.Pallets ?? 0,
cratesPerPallet = item.ProductDto?.CratesPerPallet ?? 0,
quantityOnDocument = item.QuantityOnDocument,
netWeightOnDocument = item.NetWeightOnDocument,
@@ -406,7 +407,8 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
cratesPerPallet = item.ProductDto?.CratesPerPallet ?? 0,
palletsOnDocument = item.PalletsOnDocument,
pallets = item.Pallets,
palletGroupNo = item.PalletGroupNo,
palletGroupNo = item.PalletGroup?.GroupNo,
palletGroupPallets = item.PalletGroup?.Pallets ?? 0,
quantityOnDocument = item.QuantityOnDocument,
netWeightOnDocument = item.NetWeightOnDocument,
grossWeightOnDocument = item.GrossWeightOnDocument,
@@ -1144,6 +1146,9 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
ShippingItems = new List<ShippingItem>()
};
// A tételek raklap-csoportja: a beírt sorszám tételenként, amíg a csoport-sorok meg nem születnek.
var groupNoByItem = new Dictionary<ShippingItem, int>();
// Convert DTOs to entities
foreach (var itemDto in request.ShippingItems)
{
@@ -1160,7 +1165,6 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
NameOnDocument = itemDto.NameOnDocument,
ProductId = itemDto.ProductId,
PalletsOnDocument = itemDto.PalletsOnDocument,
PalletGroupNo = itemDto.PalletGroupNo,
QuantityOnDocument = itemDto.QuantityOnDocument,
// A kiolvasott súlyok; nem mérendő terméknél a Pallets megléte után felülíródnak (lent).
NetWeightOnDocument = itemDto.NetWeightOnDocument,
@@ -1212,14 +1216,44 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
shippingItem.NetWeightOnDocument = itemDto.QuantityOnDocument * productDto.AverageWeight;
}
// A csoport-sor a dokumentum Id-je nélkül nem hozható létre, ezért a beírt sorszámot itt
// csak feljegyezzük. Referencia szerinti kulcs, nem sorrend: a tétel-ciklus kihagyhat DTO-t.
if (itemDto.PalletGroupNo is { } dtoGroupNo) groupNoByItem[shippingItem] = dtoGroupNo;
shippingDocument.ShippingItems.Add(shippingItem);
}
// ✅ STEP 1: Save shipping document to database FIRST
await _dbContext.ShippingDocuments.InsertAsync(shippingDocument);
// A raklap-csoportok a dokumentum után jönnek létre (kell az Id-je), a tételek ELŐTT: a tétel
// idegen kulcsa rájuk mutat. A csoport raklapszáma az ADMINÉ — a tételekből nem vezethető le,
// mert a raklap korlátja a magasság, nem az alapterület (ADR FBANKAPP 0006-K4T9).
var groupIdByNo = new Dictionary<int, int>();
foreach (var groupNo in groupNoByItem.Values.Distinct())
{
var group = new ShippingItemPalletGroup
{
ShippingDocumentId = shippingDocument.Id,
GroupNo = groupNo,
// Hiányzó bevitelnél 0 marad = „még nincs megadva". NEM esünk vissza a tételek összegére:
// az számnak látszana, pedig találgatás lenne, és a tervező többé nem jelezné a hiányt.
Pallets = request.PalletGroups?.FirstOrDefault(g => g.GroupNo == groupNo)?.Pallets ?? 0
};
await _dbContext.ShippingItemPalletGroups.InsertAsync(group);
groupIdByNo[groupNo] = group.Id;
}
foreach (var sItem in shippingDocument.ShippingItems)
{
sItem.ShippingDocumentId = shippingDocument.Id; // Set FK
if (groupNoByItem.TryGetValue(sItem, out var itemGroupNo)
&& groupIdByNo.TryGetValue(itemGroupNo, out var palletGroupId))
sItem.PalletGroupId = palletGroupId;
await _dbContext.ShippingItems.InsertAsync(sItem);
}
@@ -1388,6 +1422,23 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
public int TotalPallets { get; set; }
public string PdfFileName { get; set; }
public List<SaveShippingItemDto> ShippingItems { get; set; }
/// <summary>A dokumentum raklap-csoportjai: sorszámonként hány FIZIKAI raklapon utaznak együtt.</summary>
/// <remarks>
/// A tételek csak a csoport SORSZÁMÁT hordozzák; a raklapszám a csoporté, ezért itt, dokumentum-szinten
/// érkezik. Nem a tételekből számoljuk: a raklap korlátja a magasság, nem az alapterület, tehát 0,5
/// raklapnyi citromra rámehet 0,8 raklapnyi petrezselyem (ADR FBANKAPP 0006-K4T9).
/// </remarks>
public List<SavePalletGroupDto> PalletGroups { get; set; }
}
public class SavePalletGroupDto
{
/// <summary>A csoport admin-adta sorszáma a dokumentumon belül — a tételek erre hivatkoznak.</summary>
public int GroupNo { get; set; }
/// <summary>Hány fizikai raklapon utazik a csoport egésze. 0 = nincs megadva.</summary>
public int Pallets { get; set; }
}
public class SaveShippingItemDto
@@ -1402,8 +1453,9 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
/// érkezve a szerver számolja a rekesz/raklap arányból.</summary>
public double Pallets { get; set; }
/// <summary>Maradék-csoport a dokumentumon belül: az azonos számú tételek tört maradéka egy közös fizikai
/// raklapra kerül. Csak az admin adhatja meg — a szállítóleveleken nincs róla adat (ADR 0004-M9F3).</summary>
/// <summary>A tétel raklap-csoportjának SORSZÁMA a dokumentumon belül (nem azonosító). Az azonos számú
/// tételek közös fizikai raklapokon utaznak. Csak az admin adhatja meg — a szállítóleveleken nincs róla
/// adat. A csoport raklapszáma a <see cref="SaveShippingDocumentRequest.PalletGroups"/> listában jön.</summary>
public int? PalletGroupNo { get; set; }
public int QuantityOnDocument { get; set; }
@@ -32,6 +32,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
OpenAIModelName = _settings.OpenAIModelName,
IsEnabled = _settings.IsEnabled,
ApiBaseUrl = _settings.ApiBaseUrl,
OpenAIApiBaseUrl = _settings.OpenAIApiBaseUrl,
MaxTokens = _settings.MaxTokens,
Temperature = _settings.Temperature,
RequestTimeoutSeconds = _settings.RequestTimeoutSeconds,
@@ -54,9 +55,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
}
// Map model properties to settings
_settings.ApiKey = model.ApiKey ?? string.Empty;
// A titkok kizárólag KeepStoredWhenBlank-en keresztül íródnak: az üresen visszaérkező jelszó-mező
// nem törlési szándék. Közvetlen értékadás ide egyetlen mentéssel kinullázza az éles kulcsokat.
// A mentés teljes szabálya: docs/AI_SERVICES.md → Saving secrets.
_settings.ApiKey = KeepStoredWhenBlank(model.ApiKey, _settings.ApiKey);
_settings.ModelName = model.ModelName ?? string.Empty;
_settings.OpenAIApiKey = model.OpenAIApiKey ?? string.Empty;
_settings.OpenAIApiKey = KeepStoredWhenBlank(model.OpenAIApiKey, _settings.OpenAIApiKey);
_settings.OpenAIModelName = model.OpenAIModelName ?? string.Empty;
_settings.IsEnabled = model.IsEnabled;
_settings.ApiBaseUrl = model.ApiBaseUrl ?? string.Empty;
@@ -64,11 +68,11 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
_settings.MaxTokens = model.MaxTokens;
_settings.Temperature = model.Temperature;
_settings.RequestTimeoutSeconds = model.RequestTimeoutSeconds;
_settings.ZaiApiKey = model.ZaiApiKey ?? string.Empty;
_settings.ZaiApiKey = KeepStoredWhenBlank(model.ZaiApiKey, _settings.ZaiApiKey);
_settings.ZaiModel = model.ZaiModel ?? "glm-ocr";
_settings.InnVoiceCompanyName = model.InnVoiceCompanyName ?? string.Empty;
_settings.InnVoiceUsername = model.InnVoiceUsername ?? string.Empty;
_settings.InnVoicePassword = model.InnVoicePassword ?? string.Empty;
_settings.InnVoicePassword = KeepStoredWhenBlank(model.InnVoicePassword, _settings.InnVoicePassword);
_settings.InnVoiceBaseUrl = string.IsNullOrWhiteSpace(model.InnVoiceBaseUrl)
? "https://api.innvoice.hu"
: model.InnVoiceBaseUrl.Trim();
@@ -79,5 +83,14 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
_notificationService.SuccessNotification("Beállítások mentve.");
return RedirectToAction("Configure");
}
/// <summary>Üresen visszaérkező jelszó-mező esetén a tárolt titkot tartja meg.</summary>
/// <remarks>
/// A <c>type="password"</c> input sosem kapja meg a <c>value</c> attribútumot (ASP.NET Core
/// <c>InputTagHelper</c>), így az érintetlen mező üresen posztol vissza. A törlés szándékos esete
/// szóközzel nem megkülönböztethető, ezért a kulcs törlése az adatbázisban történik, nem itt.
/// </remarks>
private static string KeepStoredWhenBlank(string posted, string stored)
=> string.IsNullOrWhiteSpace(posted) ? stored : posted.Trim();
}
}
@@ -213,27 +213,27 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
// Save InnVoice order details as attributes
//await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Order, string>(
// order.Id,
// "InnVoiceOrderTechId",
// FruitBankPluginConst.InnVoiceOrderTechIdAttribute,
// response.TechId,
// storeId
//);
await _genericAttributeService.SaveAttributeAsync(
order,
"InnVoiceOrderTechId",
FruitBankPluginConst.InnVoiceOrderTechIdAttribute,
response.TechId,
storeId
);
await _genericAttributeService.SaveAttributeAsync(
order,
"InnVoiceOrderTableId",
FruitBankPluginConst.InnVoiceOrderTableIdAttribute,
response.TableId?.ToString() ?? string.Empty,
storeId
);
await _genericAttributeService.SaveAttributeAsync(
order,
"InnVoiceOrderPrintLink",
FruitBankPluginConst.InnVoiceOrderPrintLinkAttribute,
response.PrintUrl?.ToString() ?? string.Empty,
storeId
);
@@ -290,13 +290,13 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
// Get saved InnVoice order TechId
var techId = await _genericAttributeService.GetAttributeAsync<string>(
order,
"InnVoiceOrderTechId",
FruitBankPluginConst.InnVoiceOrderTechIdAttribute,
storeId
);
var printLink = await _genericAttributeService.GetAttributeAsync<string>(
order,
"InnVoiceOrderPrintLink",
FruitBankPluginConst.InnVoiceOrderPrintLinkAttribute,
storeId
);
@@ -438,14 +438,14 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
{
await _genericAttributeService.SaveAttributeAsync(
order,
"InnVoiceOrderTechId",
FruitBankPluginConst.InnVoiceOrderTechIdAttribute,
responses[i].TechId,
storeId
);
await _genericAttributeService.SaveAttributeAsync(
order,
"InnVoiceOrderTableId",
FruitBankPluginConst.InnVoiceOrderTableIdAttribute,
responses[i].TableId?.ToString(),
storeId
);
@@ -78,7 +78,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
{
var tableIdStr = await _genericAttributeService.GetAttributeAsync<string>(
order,
"InnVoiceOrderTableId",
FruitBankPluginConst.InnVoiceOrderTableIdAttribute,
storeId
);
@@ -104,17 +104,13 @@ public class VatReportController(
var requestedFrom = (from ?? new DateTime(today.Year, today.Month, 1)).Date;
var toExclusive = to?.Date ?? new DateTime(today.Year, today.Month, 1).AddMonths(1);
// Az adathatár alá nem megyünk: a kimenő oldal ennél korábbra nem létezik, és a nulla kimenő áfa
// hiányzó adatot jelentene, nem valós nullát. Csendben felhúzzuk, de a felület megmondja, hogy tette.
var floor = VatReportService.DataAvailableFrom;
var fromInclusive = requestedFrom < floor ? floor : requestedFrom;
// Alsó adathatár nincs: mindkét oldal odáig megy vissza, ameddig az adat. A korábbi korlát a
// fogyasztás-sorok kezdetét takarta, az pedig már nem forrása a kimenő oldalnak (ADR 0004-K9T3).
var fromInclusive = requestedFrom;
if (toExclusive <= fromInclusive) toExclusive = fromInclusive.AddMonths(1);
var filter = new VatReportInvoiceFilter { Text = text, Currency = currency, OnlyWithVat = onlyWithVat };
var model = await vatReportService.CalculateAsync(fromInclusive, toExclusive, filter);
model.FromClamped = requestedFrom < floor;
return model;
return await vatReportService.CalculateAsync(fromInclusive, toExclusive, filter);
}
}
@@ -21,9 +21,10 @@
<div class="form-group">
<label asp-for="ApiKey"></label>
<input asp-for="ApiKey" class="form-control" type="password" placeholder="Adja meg az AI API kulcsot" />
@* autocomplete="new-password": e nélkül a böngésző jelszókezelője a saját admin jelszót tölti a mezőbe, és a mentés azt írja a kulcs helyére. *@
<input asp-for="ApiKey" class="form-control" type="password" autocomplete="new-password" placeholder="Csak változtatáskor töltse ki" />
<span asp-validation-for="ApiKey" class="text-danger"></span>
<small class="form-text text-muted">A Cerebras API kulcs</small>
<small class="form-text text-muted">A Cerebras API kulcs. <strong>Üresen hagyva a tárolt kulcs marad érvényben.</strong></small>
</div>
<div class="form-group">
@@ -35,9 +36,9 @@
<div class="form-group">
<label asp-for="OpenAIApiKey"></label>
<input asp-for="OpenAIApiKey" class="form-control" type="password" placeholder="Adja meg az OpenAI API kulcsot" />
<input asp-for="OpenAIApiKey" class="form-control" type="password" autocomplete="new-password" placeholder="Csak változtatáskor töltse ki" />
<span asp-validation-for="OpenAIApiKey" class="text-danger"></span>
<small class="form-text text-muted">Az OpenAI API kulcs</small>
<small class="form-text text-muted">Az OpenAI API kulcs. <strong>Üresen hagyva a tárolt kulcs marad érvényben.</strong></small>
</div>
<div class="form-group">
@@ -100,9 +101,9 @@
<div class="form-group">
<label asp-for="ZaiApiKey"></label>
<input asp-for="ZaiApiKey" class="form-control" type="password" placeholder="Adja meg a Z.ai API kulcsot" />
<input asp-for="ZaiApiKey" class="form-control" type="password" autocomplete="new-password" placeholder="Csak változtatáskor töltse ki" />
<span asp-validation-for="ZaiApiKey" class="text-danger"></span>
<small class="form-text text-muted">Z.ai API kulcs (bigmodel.cn). Üres hagyva a GLM-OCR funkció nem érhető el.</small>
<small class="form-text text-muted">Z.ai API kulcs (bigmodel.cn). <strong>Üresen hagyva a tárolt kulcs marad érvényben.</strong></small>
</div>
<div class="form-group">
@@ -128,14 +129,15 @@
<div class="form-group">
<label asp-for="InnVoiceUsername"></label>
<input asp-for="InnVoiceUsername" class="form-control" placeholder="API felhasználónév" />
<input asp-for="InnVoiceUsername" class="form-control" autocomplete="off" placeholder="API felhasználónév" />
<span asp-validation-for="InnVoiceUsername" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="InnVoicePassword"></label>
<input asp-for="InnVoicePassword" class="form-control" type="password" placeholder="API jelszó" />
<input asp-for="InnVoicePassword" class="form-control" type="password" autocomplete="new-password" placeholder="Csak változtatáskor töltse ki" />
<span asp-validation-for="InnVoicePassword" class="text-danger"></span>
<small class="form-text text-muted"><strong>Üresen hagyva a tárolt jelszó marad érvényben.</strong></small>
</div>
<div class="form-group">
@@ -160,6 +160,23 @@
<div id="shippingItemsContainer">
</div>
<!-- Összevont raklapok: a mező a CSOPORTÉ, nem a tételé, ezért itt áll és nem a kártyákon.
Csak akkor jelenik meg, ha a tételek közt van csoportszám. -->
<div class="card mt-3" id="palletGroupsCard" style="display:none;">
<div class="card-header py-2">
<strong>Összevont raklapok</strong>
<small class="text-muted ml-2">hány fizikai raklapon utaznak együtt</small>
</div>
<div class="card-body p-3">
<div id="palletGroupsContainer"></div>
<small class="text-muted d-block mt-2">
A raklap korlátja a <strong>magasság</strong>, nem az alapterület: 0,5 raklap citromra rámehet
0,8 raklap petrezselyem. A tételek raklapszámának összege ezért csak tájékoztatás — a valós
számot csak te tudod, és nem ellenőrizzük ellene.
</small>
</div>
</div>
<!-- Save Button -->
<div class="card mt-3 mb-4">
<div class="card-body p-3">
@@ -460,6 +477,10 @@
let selectedFile = null;
let shippingItems = [];
// Csoportszám -> hány fizikai raklapon utazik a csoport. A tételek csak a SORSZÁMOT hordozzák; ez a szám a
// csoporté, ezért külön él, és dokumentum-szinten megy fel a mentéssel (ADR FBANKAPP 0006-K4T9).
let palletGroupPallets = {};
let currentPartner = { id: null, name: '', taxId: '' };
let originalUploadedFile = null;
let extractedFullText = '';
@@ -629,6 +650,82 @@
}
// ─── Shipping Item Cards ───
// A tételekbe beírt, különböző csoportszámok — növekvő sorrendben. Az admin bármit beírhat (7-et 1 nélkül is),
// ezért nem 1..N-t generálunk, hanem azt olvassuk vissza, ami tényleg szerepel.
function palletGroupNumbers() {
const seen = new Set();
shippingItems.forEach(item => {
if (item.palletGroupNo) seen.add(item.palletGroupNo);
});
return [...seen].sort((a, b) => a - b);
}
// A csoport tagjainak raklapszám-összege — CSAK tájékoztatás. Nem alsó korlát, és nem alapérték:
// a raklap korlátja a magasság, nem az alapterület (ADR 0006-K4T9, 8. pont).
function palletGroupComputed(groupNo) {
return shippingItems
.filter(item => item.palletGroupNo === groupNo)
.reduce((sum, item) => sum + (parseFloat(item.pallets) || 0), 0);
}
function renderPalletGroups() {
const card = document.getElementById('palletGroupsCard');
const container = document.getElementById('palletGroupsContainer');
if (!card || !container) return;
const groups = palletGroupNumbers();
card.style.display = groups.length > 0 ? '' : 'none';
if (groups.length === 0) return;
// A már nem létező csoportok értékét eldobjuk, különben egy átírt sorszám régi számot vinne fel.
Object.keys(palletGroupPallets).forEach(key => {
if (!groups.includes(parseInt(key))) delete palletGroupPallets[key];
});
// Meglévő dokumentum szerkesztésekor a MENTETT raklapszám a kiindulás; a payload hozza a tételeken.
// Új kiolvasásnál ez 0, tehát a mező üresen marad — szándékosan, hogy az admin tényleg ránézzen.
groups.forEach(no => {
if (palletGroupPallets[no] > 0) return;
const stored = shippingItems.find(item => item.palletGroupNo === no && item.palletGroupPallets > 0);
if (stored) palletGroupPallets[no] = stored.palletGroupPallets;
});
container.innerHTML = groups.map(no => {
const members = shippingItems.filter(item => item.palletGroupNo === no).length;
const computed = palletGroupComputed(no);
const value = palletGroupPallets[no] || '';
// Egytagú csoport szinte biztosan elgépelés — jelezzük, de nem tiltjuk.
const lonely = members < 2
? `<span class="text-warning ml-2" title="Ebben a csoportban csak egy tétel van — összevonni nincs mivel. Elgépelted a sorszámot?"><i class="fas fa-exclamation-triangle"></i> egytagú</span>`
: '';
return `
<div class="form-group row mb-2 align-items-center">
<label class="col-sm-7 col-form-label col-form-label-sm mb-0">
A(z) <strong>${no}.</strong> számú összevont csoport raklapfoglalása:
<small class="text-muted ml-1">${members} tétel · számszakilag ${computed.toFixed(3)}</small>
${lonely}
</label>
<div class="col-sm-5">
<input type="number" step="1" min="1" class="form-control form-control-sm"
value="${value}" data-group-no="${no}" placeholder="hány raklap?">
</div>
</div>`;
}).join('');
container.querySelectorAll('input[data-group-no]').forEach(input => {
input.addEventListener('input', e => {
const no = parseInt(e.target.dataset.groupNo);
palletGroupPallets[no] = parseInt(e.target.value) || 0;
});
});
}
function renderShippingItems() {
const container = document.getElementById('shippingItemsContainer');
container.innerHTML = '';
@@ -647,6 +744,7 @@
});
}
updateTotalPallets();
renderPalletGroups();
}
function createItemCard(item, index) {
@@ -708,7 +806,7 @@
<label>Összevont raklap <small class="text-muted">(közös raklapon érkezik)</small></label>
<input type="number" step="1" min="1" class="form-control form-control-sm"
value="${item.palletGroupNo ?? ''}" data-field="palletGroupNo" data-index="${index}"
placeholder="—" title="Az azonos számú tételek TÖRT maradéka egy közös fizikai raklapra kerül. Üresen hagyva a tétel önállóan foglal helyet.">
placeholder="—" title="Az azonos számú tételek közös fizikai raklapokon utaznak. A csoport raklapszámát lent, a mentés gomb fölött kell megadni. Üresen hagyva a tétel önállóan foglal helyet.">
</div>
<div>
<label>Nettó súly (kg) ${weightHint(item, item.netWeightOnDocument)}</label>
@@ -756,6 +854,9 @@
shippingItems[idx][field] = value;
// A csoport-panel a beírt sorszámokból épül, tehát gépelés közben követnie kell.
if (field === 'palletGroupNo') renderPalletGroups();
// A nettó tétel-összeg a fajlagos önköltség horgonya (ADR 0002-R7Q2). Ha még nincs kitöltve,
// az egységár × mennyiség szorzatból előálljon MENTÉS ELŐTT — ugyanaz a tartalék, mint a szerveren,
// csak itt az admin látja is, és felülírhatja. A már beírt értéket nem bántjuk.
@@ -856,7 +957,7 @@
function addNewShippingItem() {
shippingItems.push({
name: '', hungarianName: '', nameOnDocument: '', productId: null,
palletsOnDocument: 0, pallets: 0, palletGroupNo: null, cratesPerPallet: 0,
palletsOnDocument: 0, pallets: 0, palletGroupNo: null, palletGroupPallets: 0, cratesPerPallet: 0,
quantityOnDocument: 0, netWeightOnDocument: 0,
grossWeightOnDocument: 0, unitPriceOnDocument: 0,
netTotalOnDocument: 0, vatRate: 0
@@ -998,7 +1099,13 @@
shippingItems[itemIndex] = {
...shippingItems[itemIndex],
productId: product.value, name, hungarianName: name,
unitPriceOnDocument: product.price || 0,
// ⚠️ Az egységárat a termékcsere NEM írja át. A ...OnDocument mezők a szállítólevél NYERS
// állításai; a termék-törzsadat nem érvényteleníti őket — ugyanaz a fegyelem, mint a súly-mezőknél.
// Korábban itt `unitPriceOnDocument: product.price || 0` állt, ami két úton is kárt tett:
// • a dokumentumról kiolvasott egységárat eldobta — ár nélküli terméknél NULLÁRA;
// • ezt az értéket az EKÁER `UnitPriceOnDocument × QuantityOnDocument`-ként JELENTI BE a NAV-nak
// (FmEkaerValueCalculator.ItemLineValue), és a `product.ProductCost` is ebből íródik.
// Hiányzó egységárat az admin ír be; helyette találgatni nem szabad.
// A termékkel jön a rekesz/raklap arány is: ebből azonnal kiszámoljuk a tényleges raklapigényt,
// és eltűnik a „nincs rekesz/raklap arány" jelzés (ADR 0004-M9F3). Ha a terméken nincs kitöltve,
// a jelzés marad, a mező pedig kézzel írható.
@@ -1048,12 +1155,25 @@
return;
}
// A csoport raklapszáma KÖTELEZŐ, és szándékosan nincs előtöltve: egy kész szám mellett az admin
// leütné az Entert, és épp azt a megfigyelést hagyná ki, ami az egyetlen forrás (ADR 0006-K4T9, 3. pont).
const missingGroups = palletGroupNumbers().filter(no => !(palletGroupPallets[no] > 0));
if (missingGroups.length > 0) {
showMessage('Az összevont raklapoknál kötelező megadni, hány fizikai raklapon utaznak együtt. '
+ 'Hiányzik: ' + missingGroups.map(no => no + '. csoport').join(', '), 'warning');
renderPalletGroups();
document.getElementById('palletGroupsCard').scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
const doc = {
documentIdNumber: document.getElementById('editDocumentIdNumber').value,
partnerId: currentPartner.id,
totalPallets: parseInt(document.getElementById('editTotalPallets').value) || 0,
pdfFileName: document.getElementById('editPdfFileName').value,
shippingItems: shippingItems
shippingItems: shippingItems,
palletGroups: palletGroupNumbers().map(no => ({ groupNo: no, pallets: palletGroupPallets[no] }))
};
try {
@@ -218,10 +218,18 @@
Name = "products-grid",
UrlRead = new DataUrl("ProductList", "CustomProduct", null),
SearchButtonId = "search-products",
Length = Model.PageSize,
// Ordering = true,
// ServerSide = false,
LengthMenu = Model.AvailablePageSizes,
// Alapból 100 sor: ~70 aktív termék van, tehát egy lapon elfér az egész napi munka.
Length = 100,
// Rendezés ÉS oszlop-szűrés a böngészőben. A saját oszlopok (tára, átlagsúly,
// mérendő, kamionon, elérhető) a lapozott találatra UTÓLAG kerülnek rá
// (CustomProductModelFactory), tehát nincsenek benne a SQL-ben — szerver-oldalon
// rendezni őket csak oldalanként lehetne, ami helyesnek látszik és téved.
// Kliens-oldalon viszont mind rendezhető és szűrhető, egyformán.
Ordering = true,
ServerSide = false,
// A menüben szerepelnie KELL a 100-nak, különben a DataTables az első menüpontra esne
// vissza, és a fenti alapérték néma módon nem érvényesülne.
LengthMenu = "20,50,100,200,500",
Filters = new List<FilterParameter>
{
new FilterParameter(nameof(Model.SearchProductName)),
@@ -349,6 +357,182 @@
});
</script>
<script>
/* ── Gyorsszűrő sor a termék-gridhez ──────────────────────────────────────
Két dolgot kell kikerülnie, és mindkettő megharapott minket:
1) A grid `scrollX: true`-val megy, ezért a DataTables a fejlécet KLÓNOZZA egy külön,
görgethető táblába, és minden rajzoláskor újraklónozza. Az elemekre kötött jQuery
eseménykezelők a klónra NEM másolódnak át. Ezért a sor a törzs-tábla fejlécébe
épül (azt klónozza a DataTables, tehát magától visszaépül), az események pedig
DELEGÁLTAK, `data-fb-col` attribútum alapján.
2) A DataTables a kereséshez KISZEDI a HTML-t a cellákból. A logikai oszlopokat a nop
ikonra rendereli (`<i class="fas fa-check true-icon">`), tehát a kirenderelt
osztálynévre keresni sosem talál — üres eredményt ad, ami hibátlan szűrőnek látszik.
Ezért minden nem-szöveges szűrő a NYERS sor-objektumot nézi (`ext.search` 4.
paramétere), nem a megjelenítést.
A definíciók az OSZLOP ADATNEVÉRE kulcsolnak, nem a sorszámára: az `Id` háromszor is
oszlop (kijelölő, azonosító, szerkesztés-gomb), és a rejtett kép-oszlop miatt a
pozíciók amúgy is elcsúsznának. */
(function () {
var GRID = "products-grid";
/* Oszlop adatneve → a beírt szűrő-érték. A klónok ebből épülnek vissza. */
var state = {};
/* Adatnév → oszlop-index. A nop NEM ad `name` propertyt az oszlopoknak, ezért a
`"Name:name"` szelektor nem oldódna fel — némán nem szűrne semmit. */
var colIndex = {};
var yesNo = [["", "Mind"], ["true", "Igen"], ["false", "Nem"]];
var defs = {
"Name" : { kind: "text" },
"Sku" : { kind: "text" },
/* Az „elérhető" MENNYISÉG: a részszó-keresés félrevezetne (a „10" a 100-ra és a
210-re is illeszkedne), ezért van/nincs bontás, számként összehasonlítva. */
"AvailableQuantity": { kind: "qty", opts: [["", "Mind"], ["pos", "Van (> 0)"], ["zero", "Nincs (0)"]] },
"IsMeasurable" : { kind: "bool", opts: yesNo },
"Published" : { kind: "bool", opts: yesNo }
};
/* A nyers soron dolgozó szűrők. Az ext.search GLOBÁLIS: minden gridre lefut, ezért a
sajátunkra kell szűkíteni. A 4. paraméter a JSON-ból jött sor-objektum — pont az, amit
a HTML-kiszedés nem érint. */
$.fn.dataTable.ext.search.push(function (settings, searchData, dataIndex, row) {
if (settings.nTable.id !== GRID || !row) return true;
for (var key in defs) {
var def = defs[key];
var want = state[key];
if (def.kind === "text" || !want) continue;
if (def.kind === "bool") {
if (String(row[key]) !== want) return false;
continue;
}
var value = parseFloat(row[key]);
if (isNaN(value)) value = 0;
if (want === "pos" && !(value > 0)) return false;
if (want === "zero" && value > 0) return false;
}
return true;
});
function buildRow(api, $header) {
var $row = $("<tr class='fb-filter-row'></tr>");
api.columns().every(function (idx) {
/* A rejtett kép-oszlopnak fejléc-cellája sincs — egy fölösleges th elcsúsztatná a sort. */
if (!this.visible()) return;
var key = this.dataSrc();
colIndex[key] = idx;
var $th = $("<th></th>").appendTo($row);
var def = defs[key];
if (!def) return;
var value = state[key] || "";
var $field;
if (def.kind === "text") {
$field = $("<input type='text' class='form-control form-control-sm'>")
.attr("placeholder", "szűrés…").attr("value", value);
} else {
$field = $("<select class='form-control form-control-sm'></select>");
def.opts.forEach(function (o) {
var $o = $("<option>").val(o[0]).text(o[1]);
/* selected ATTRIBÚTUM, nem property: a klón csak az attribútumot viszi magával. */
if (o[0] === value) $o.attr("selected", "selected");
$o.appendTo($field);
});
}
$field.attr("data-fb-col", key).attr("data-fb-kind", def.kind).val(value).appendTo($th);
});
/* A TÖRZS-tábla fejlécébe kötünk: scrollX mellett ezt klónozza a DataTables a látható
görgető-fejlécbe, minden rajzoláskor újra — így a sor magától visszaépül. Közvetlenül
a klónba írni hasztalan: a következő rajzolás felülírja. */
$header.append($row);
}
$(document).on("init.dt", function (e, settings) {
if (settings.nTable.id !== GRID) return;
buildRow(new $.fn.dataTable.Api(settings), $(settings.nTable).find("thead").first());
});
function syncCopies(selector, key, value) {
$(selector + "[data-fb-col='" + key + "']").each(function () {
if (this.value !== value) this.value = value;
if (this.tagName === "SELECT") {
$(this).find("option").each(function () {
$(this).attr("selected", this.value === value ? "selected" : null);
});
} else {
$(this).attr("value", value);
}
});
}
/* Delegálva a dokumentumra: a klónok bármikor keletkezhetnek, kezelőt nem kapnak. */
var timer;
$(document).on("input", "input[data-fb-col]", function () {
var key = $(this).attr("data-fb-col");
var value = this.value;
state[key] = value;
syncCopies("input", key, value);
clearTimeout(timer);
timer = setTimeout(function () {
var api = $("#" + GRID).DataTable();
api.column(colIndex[key]).search(value);
api.draw();
}, 300);
});
$(document).on("change", "select[data-fb-col]", function () {
var key = $(this).attr("data-fb-col");
state[key] = this.value;
syncCopies("select", key, this.value);
/* A logikai és a mennyiségi szűrő az ext.search-ben fut, ezért itt elég újrarajzolni. */
$("#" + GRID).DataTable().draw();
});
/* A kliens-oldali mód a kapott sorokat tekinti a teljes halmaznak. Ha a szerver a felső
korlátnál (ProductGridMaxRows) elvágta a listát, azt LÁTNI kell — különben a rendezés
és a szűrés némán a katalógus egy részén dolgozna.
A nop `dataSrc: "Data"`-val ad vissza, tehát a mező NAGY kezdőbetűs. */
$(document).on("xhr.dt", function (e, settings, json) {
if (settings.nTable.id !== GRID || !json || !json.recordsTotal) return;
var got = (json.Data || []).length;
if (json.recordsTotal <= got) return;
$("#" + GRID + "_wrapper").prepend(
"<div class='alert alert-warning py-2 mb-2'>A lista csonkolva: " + got + " / " +
json.recordsTotal + " termék jött le. A rendezés és a szűrés csak a letöltött részen " +
"dolgozik — szűkíts a fenti kereső-panellel.</div>");
});
})();
</script>
<nop-alert asp-alert-id="deleteSelectedFailed" />
<nop-alert asp-alert-id="nothingSelectedAlert" />
</div>
@@ -24,7 +24,10 @@
<div class="card-body py-2">
<p class="mb-1 text-muted">
<strong>Bejövő:</strong> az InnVoice-ból tükrözött szállítói számlák, a <strong>teljesítés kelte</strong>
szerinti hónapban. <strong>Kimenő:</strong> a saját lezárt rendeléseink áfája, a lezárás hónapjában.
szerinti hónapban. <strong>Kimenő:</strong> a saját lezárt rendeléseink áfája, a lezárás hónapjában
a tétel <strong>bruttó</strong> végösszegéből, egységes
<strong>@((Model.OutgoingVatRate * 100).ToString("0.##"))%</strong>-os kulccsal,
ugyanazzal, amivel a számla kimegy.
</p>
<p class="mb-1 text-muted">
⚠️ <strong>Belső tájékozódásra</strong>, nem bevallás: nincs benne fordított adózás, arányosítás,
@@ -49,10 +52,10 @@
<form method="get" class="form-inline">
<label class="mr-2">Ettől:</label>
<input type="date" name="from" value="@Model.FromInclusive.ToString("yyyy-MM-dd")"
min="@Model.DataAvailableFrom.ToString("yyyy-MM-dd")" class="form-control mr-3" />
class="form-control mr-3" />
<label class="mr-2">Eddig:</label>
<input type="date" name="to" value="@Model.ToExclusive.ToString("yyyy-MM-dd")"
min="@Model.DataAvailableFrom.ToString("yyyy-MM-dd")" class="form-control mr-3" />
class="form-control mr-3" />
<label class="mr-2">Szállító / bizonylat:</label>
<input type="text" name="text" value="@Model.Filter.Text" placeholder="névrészlet vagy szám"
class="form-control mr-3" style="min-width:220px;" />
@@ -78,26 +81,19 @@
}
</form>
<p class="mb-0 mt-2 text-muted small">
A kimutatás <strong>@Model.DataAvailableFrom.ToString("yyyy.MM.dd.")</strong> előttre nem kérdezhető le.
A bejövő számlák ugyan korábbra is megvannak a tükörben, de az <strong>eladási oldal</strong> csak
ettől a naptól áll össze — korábbra a nulla kimenő áfa hiányzó adatot jelentene, nem valós nullát.
A <strong>bejövő</strong> oldal hónapja a számla teljesítés-kelte, a <strong>kimenő</strong> oldalé
az <strong>átvétel időpontja</strong> a rendelésen. Az árrés-kimutatás ettől eltérően a lezárás
hónapja szerint csoportosít — a két riport szándékosan más naptárt követ.
</p>
</div>
</div>
@* ── Figyelmeztetések ───────────────────────────────────────── *@
@if (Model.FromClamped)
{
<div class="alert alert-info">
A kért kezdő dátum korábbi volt az adathatárnál, ezért
<strong>@Model.DataAvailableFrom.ToString("yyyy.MM.dd.")</strong>-tól számoltunk.
</div>
}
@if (Model.WithoutPerformanceDate > 0 || Model.ImplausiblePerformanceDate > 0)
@if (Model.WithoutPerformanceDate > 0 || Model.ImplausiblePerformanceDate > 0
|| Model.WithoutPickupDate > 0 || Model.UnparsablePickupDate > 0)
{
<div class="alert alert-warning">
<strong>Egyik hónapba sem esik bele</strong> (a teljes tükörben, nem csak ebben az időszakban):
<strong>Egyik hónapba sem esik bele</strong> (a teljes adatállományban, nem csak ebben az időszakban):
@if (Model.WithoutPerformanceDate > 0)
{
<text>@Model.WithoutPerformanceDate számla teljesítés-dátum nélkül. </text>
@@ -106,8 +102,16 @@
{
<text>@Model.ImplausiblePerformanceDate számla értelmetlen teljesítés-dátummal (elgépelt évszám). </text>
}
Ezek a sorok <strong>egyetlen hónap összegében sem szerepelnek</strong> — az InnVoice-ban kell javítani a dátumot,
utána egy szinkron átveszi.
@if (Model.WithoutPickupDate > 0)
{
<text><strong>@Model.WithoutPickupDate lezárt rendelés átvételi időpont nélkül.</strong> </text>
}
@if (Model.UnparsablePickupDate > 0)
{
<text>@Model.UnparsablePickupDate rendelés értelmezhetetlen átvételi időponttal. </text>
}
Ezek a sorok <strong>egyetlen hónap összegében sem szerepelnek</strong>. A számla dátumát az InnVoice-ban
kell javítani (utána egy szinkron átveszi), az átvételi időpontot a rendelés lapján.
</div>
}
@@ -234,7 +238,7 @@
<strong class="@(row.Balance >= 0 ? "text-danger" : "text-success")">@Huf(row.Balance)</strong>
</td>
<td class="small text-muted">
@row.IncomingInvoiceCount forintos bejövő számla · @row.OutgoingOrderItemCount eladott tétel
@row.IncomingInvoiceCount forintos bejövő számla · @row.OutgoingOrderCount lezárt rendelés
@foreach (var currency in foreign)
{
<br />
@@ -249,14 +253,13 @@
{
<br /><span>@row.SetAsideCount félretett számla (benne van az összegben)</span>
}
@if (row.MissingOrderItemCount > 0)
@* A be nem küldött rendelés BENNE van az összegben — a számláló azt mondja
meg, mennyivel állhat a kimutatás a könyvelés kiszámlázott száma FÖLÖTT. *@
@if (row.NotSubmittedCount > 0)
{
<br /><span class="text-danger">@row.MissingOrderItemCount eladott tétel nem található — a kimenő ÁFA ennyivel kevesebb</span>
<br /><span class="text-danger">@row.NotSubmittedCount lezárt rendelés nem ment ki az InnVoice-ba — benne van az összegben</span>
}
@* A kimenő oldal a fogyasztás-sorokra épül, azok pedig csak az árrés-funkció
élesítése óta keletkeznek. Egy korábbi hónapban a bejövő valós, a kimenő
nulla — az egyenleg ilyenkor nem hiányzó áfa, hanem hiányzó ADAT. *@
@if (row.IncomingInvoiceCount > 0 && row.OutgoingOrderItemCount == 0)
@if (row.IncomingInvoiceCount > 0 && row.OutgoingOrderCount == 0)
{
<br /><span class="text-danger"><strong>Nincs eladási adat ebben a hónapban</strong> — az egyenleg félrevezető</span>
}
@@ -1186,6 +1186,27 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
return (document.Partner?.CountryCode, PlannerCountrySource.Partner);
}
// ── Raklap-csoportok ───────────────────────────────────────────────────
// A csoport sorszáma és admin-adta raklapszáma a CSOPORT során él, nem a tételeken (ADR 0006-K4T9).
// Egyben hozzuk le: a tervező minden rakomány-sorhoz odateszi, tehát soronkénti lekérdezés N+1 lenne.
var groupIds = items.Where(si => si.PalletGroupId.HasValue)
.Select(si => si.PalletGroupId.Value)
.Distinct()
.ToList();
var groupsById = groupIds.Count == 0
? []
: await AsyncExtensions.ToDictionaryAsync(
ctx.ShippingItemPalletGroups.GetAll().Where(g => groupIds.Contains(g.Id)),
g => g.Id, g => g);
// Hiányzó csoport-sor esetén a sorszám null és a foglaltság 0 — a sor ilyenkor csoport nélküliként
// viselkedik, ami a régi (P8 előtti) adatnál a helyes esés.
(int? No, int Pallets) GroupOf(int? groupId)
=> groupId is { } id && groupsById.TryGetValue(id, out var group)
? (group.GroupNo, group.Pallets)
: (null, 0);
// ── Kamionok + rakomány ────────────────────────────────────────────────
var allocationsByShipping = items
.SelectMany(si => si.Allocations ?? [])
@@ -1223,9 +1244,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
.Any(p => p.ShippingItemToShippingId == allocation.Id && p.IsMeasured),
Quantity = allocation.Quantity,
Pallets = allocation.Pallets,
// A tételről öröklődik: az azonos csoportú sorok tört maradéka EGY fizikai raklapon
// utazik, ezért a foglaltság-számításnál (és a mozgatásnál) össze kell tartozniuk.
PalletGroupNo = item.PalletGroupNo,
// A tételről öröklődik: a csoport tagjai közös fizikai raklapokon utaznak, ezért a
// foglaltság-számításnál (és a mozgatásnál) össze kell tartozniuk. A GroupNo a felület
// felirata, a GroupPallets a kapacitás — mindkettő a csoport sorából.
PalletGroupId = item.PalletGroupId,
PalletGroupNo = GroupOf(item.PalletGroupId).No,
PalletGroupPallets = GroupOf(item.PalletGroupId).Pallets,
WeightKg = ShippingCapacityHelper.PlanningWeightKgFor(item, allocation.Quantity)
});
}
@@ -1265,7 +1289,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
ShippingDate = item.ShippingDocument?.ShippingDate ?? default,
RemainingQuantity = remainingQuantity,
RemainingPallets = Math.Max(0d, remainingPallets),
PalletGroupNo = item.PalletGroupNo,
PalletGroupId = item.PalletGroupId,
PalletGroupNo = GroupOf(item.PalletGroupId).No,
PalletGroupPallets = GroupOf(item.PalletGroupId).Pallets,
// Nincs a terméken rekesz/raklap arány → a raklapszám nem kalkulált, hanem dokumentum-becslés.
// A felület ezt jelzi, a termék admin-linkjével, hogy egy kattintással pótolható legyen.
PalletsNotCalculated = !ShippingCapacityHelper.CanCalculatePallets(item),
@@ -1305,6 +1331,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
int Capacity(PlannerTruck truck)
=> ShippingCapacityHelper.PalletCapacity(truck.IsPartial, truck.AvailablePallets);
static ShippingCapacityHelper.PalletLoadLine AsLoadLine(PlannerItem item)
=> new(item.RemainingPallets, item.PalletGroupId, item.PalletGroupPallets);
// Futó terhelés kamiononként (a meglévő rakomány a kiindulás), és a kamion aktuális országa (üresnél null).
// A LEZÁRT kamionokra nem pakolunk (a modell azért tartalmazza őket, hogy a tervezőből újranyithatók legyenek).
// A dátumhatáron KÍVÜLRŐL behúzott kamionokra sem: azok azért vannak a modellben, hogy a rájuk osztott
@@ -1313,12 +1342,13 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
.Where(t => !t.IsClosed && t.ShippingDate >= fromDate)
.ToList();
// A raklap-oldal NEM additív (ADR 0004-M9F3): hogy egy tétel felrakása 0 vagy 1 extra raklaphelyet
// jelent-e, attól függ, van-e már fent csoporttársa. Ezért nem futó összeget viszünk, hanem a rakomány
// ÖSSZETÉTELÉT, és minden próbánál a végállapotra számolunk foglaltságot.
// A raklap-oldal NEM additív (ADR 0006-K4T9): egy csoportos tétel felrakása 0 extra helyet jelent, ha a
// csoport már fent van, és a csoport TELJES raklapszámát, ha nem. Ezért nem futó összeget viszünk, hanem a
// rakomány ÖSSZETÉTELÉT, és minden próbánál a végállapotra számolunk foglaltságot.
var loadedLines = openTrucks.ToDictionary(
t => t.ShippingId,
t => t.Load.Select(l => (l.Pallets, l.PalletGroupNo)).ToList());
t => t.Load.Select(l => new ShippingCapacityHelper.PalletLoadLine(
l.Pallets, l.PalletGroupId, l.PalletGroupPallets)).ToList());
var loadedKg = openTrucks.ToDictionary(t => t.ShippingId, t => t.Load.Sum(l => l.WeightKg));
var truckCountry = openTrucks.ToDictionary(t => t.ShippingId, t => t.CountryCode);
@@ -1338,12 +1368,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
.OrderBy(t => truckCountry[t.ShippingId] == null ? 1 : 0)
.ThenBy(t => t.ShippingDate).ThenBy(t => t.ShippingId)
.FirstOrDefault(t => ShippingCapacityHelper.OccupiedPallets(
[.. loadedLines[t.ShippingId], (item.RemainingPallets, item.PalletGroupNo)]) <= Capacity(t)
[.. loadedLines[t.ShippingId], AsLoadLine(item)]) <= Capacity(t)
&& loadedKg[t.ShippingId] + item.RemainingWeightKg <= capacityKg);
if (target == null) { result.Unplaced.Add(item); continue; }
loadedLines[target.ShippingId].Add((item.RemainingPallets, item.PalletGroupNo));
loadedLines[target.ShippingId].Add(AsLoadLine(item));
loadedKg[target.ShippingId] += item.RemainingWeightKg;
truckCountry[target.ShippingId] ??= countryCode; // üres kamion felveszi az országot
@@ -1356,7 +1386,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
CountryCode = countryCode,
Quantity = item.RemainingQuantity,
Pallets = item.RemainingPallets,
PalletGroupId = item.PalletGroupId,
PalletGroupNo = item.PalletGroupNo,
PalletGroupPallets = item.PalletGroupPallets,
WeightKg = item.RemainingWeightKg
});
@@ -78,6 +78,9 @@ public class FruitBankDbContext : MgDbContextBase,
/// <summary>Tétel↔kamion allocationök (ADR 0003) — a kamion rakományának igazságforrása.</summary>
public ShippingItemToShippingDbTable ShippingItemAllocations { get; set; }
/// <summary>A közös fizikai raklapokon utazó tétel-csoportok, az admin-adta raklapszámukkal (ADR 0006-K4T9).</summary>
public ShippingItemPalletGroupDbTable ShippingItemPalletGroups { get; set; }
/// <summary>Fuvardíj és egyéb szállítmány-költségek (ADR 0002-R7Q2).</summary>
public ShippingCostDbTable ShippingCosts { get; set; }
@@ -117,7 +120,8 @@ public class FruitBankDbContext : MgDbContextBase,
public FruitBankDbContext(INopDataProvider dataProvider, ILockService lockService, FruitBankAttributeService fruitBankAttributeService, IStoreContext storeContext,
CargoPartnerDbTable cargoPartnerDbTable, CargoTruckDbTable cargoTruckDbTable, PartnerDbTable partnerDbTable, PartnerDepotDbTable partnerDepotDbTable, EkaerHistoryDbTable ekaerHistoryDbTable, EkaerHistoryMappingDbTable ekaerHistoryMappingDbTable, ShippingDbTable shippingDbTable, ShippingDocumentDbTable shippingDocumentDbTable, ShippingItemDbTable shippingItemDbTable,
ShippingItemPalletDbTable shippingItemPalletDbTable, ShippingItemToShippingDbTable shippingItemToShippingDbTable, FilesDbTable filesDbTable, ShippingDocumentToFilesDbTable shippingDocumentToFilesDbTable,
ShippingItemPalletDbTable shippingItemPalletDbTable, ShippingItemToShippingDbTable shippingItemToShippingDbTable,
ShippingItemPalletGroupDbTable shippingItemPalletGroupDbTable, FilesDbTable filesDbTable, ShippingDocumentToFilesDbTable shippingDocumentToFilesDbTable,
ProductDtoDbTable productDtoDbTable, OrderDtoDbTable orderDtoDbTable, OrderItemDtoDbTable orderItemDtoDbTable, OrderItemPalletDbTable orderItemPalletDbTable,
StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos, CustomerCreditDbTable customerCreditDbTable,
OrderDraftDbTable orderDraftDbTable, OrderDraftItemDbTable orderDraftItemDbTable,
@@ -166,6 +170,7 @@ public class FruitBankDbContext : MgDbContextBase,
ShippingItems = shippingItemDbTable;
ShippingItemPallets = shippingItemPalletDbTable;
ShippingItemAllocations = shippingItemToShippingDbTable;
ShippingItemPalletGroups = shippingItemPalletGroupDbTable;
ShippingDocumentToFiles = shippingDocumentToFilesDbTable;
Customers = customerRepository;
@@ -1091,29 +1096,35 @@ public class FruitBankDbContext : MgDbContextBase,
#region ShippingItemToShipping (tétel<->kamion allocation, ADR 0003)
/// <summary>A tétel maradék-csoportjának TESTVÉR-tételei: azonos dokumentumon belül azonos
/// <c>PalletGroupNo</c> (ADR 0004-M9F3, 3. és 8. pont — a csoport hatóköre a dokumentum, mert egy dokumentum
/// egy partner, és a vásárlás helyén nem pakolnak két partner áruját közös raklapra).</summary>
/// <summary>A tétel raklap-csoportjának TESTVÉR-tételei.</summary>
/// <remarks>
/// Egyetlen FK-ra kulcsol: a „csoport hatóköre a dokumentum" szabályt (ADR 0004-M9F3, 8. pont) maga a
/// csoport SORA hordozza, nem ez a feltétel. Korábban a <c>(dokumentum, sorszám)</c> páron ment, ami
/// ugyanezt adta, csak két oszlopból.
/// </remarks>
private async Task<List<ShippingItem>> GetRemainderGroupSiblingsAsync(ShippingItem item)
{
if (item.PalletGroupNo is not { } group) return [];
if (item.PalletGroupId is not { } groupId) return [];
return await ShippingItems.GetAll(false)
.Where(si => si.ShippingDocumentId == item.ShippingDocumentId
&& si.PalletGroupNo == group
&& si.Id != item.Id)
.Where(si => si.PalletGroupId == groupId && si.Id != item.Id)
.ToListAsync();
}
/// <summary>Melyik kamionon utazik már a tétel maradék-csoportjának összevont raklapja? <c>null</c>, ha még sehol.
/// <para>Csak a TÖRT maradékot hordozó allocation számít: az egész raklapok szabadon oszthatók kamionok közt,
/// a közös fizikai raklap viszont nem vágható (ADR 0004-M9F3, 7. pont).</para></summary>
/// <summary>Melyik kamionon utazik már a tétel csoportjának összevont raklapja? <c>null</c>, ha még sehol.</summary>
/// <remarks>
/// Elsősorban a TÖRT maradékot hordozó allocation jelöli ki a csoport kamionját — az a közös fizikai raklap.
/// Ha egyik testvérnek sincs törtje (a csoport csupa egész raklapból áll, mint egy teli raklap tetejére rakott
/// kis mennyiségnél), akkor bármelyik kiosztás megteszi: a csoport akkor is EGY fizikai raklapon utazik.
/// </remarks>
private async Task<int?> GetRemainderGroupTruckAsync(ShippingItem item)
{
foreach (var sibling in await GetRemainderGroupSiblingsAsync(item))
{
var allocations = await ShippingItemAllocations.GetAllByShippingItemIdAsync(sibling.Id, false).ToListAsync();
var carrier = allocations.FirstOrDefault(a => ShippingCapacityHelper.RemainderPallets(a.Pallets) > 0);
var carrier = allocations.FirstOrDefault(a => ShippingCapacityHelper.RemainderPallets(a.Pallets) > 0)
?? allocations.FirstOrDefault();
if (carrier != null) return carrier.ShippingId;
}
@@ -1126,18 +1137,19 @@ public class FruitBankDbContext : MgDbContextBase,
/// <para><paramref name="quantity"/>/<paramref name="pallets"/> ≤ 0 esetén a tétel teljes MARADÉKA kerül ki
/// (ez a tervező egész-tételes pakolása). Ha a tétel már ki van osztva erre a kamionra, a meglévő allocation NŐ,
/// nem jön létre duplikátum. Egyetlen sor írása, ezért nincs tranzakció-wrapper.</para></summary>
/// <para>A maradék-csoport EGYBEN kerül fel (ADR 0004-M9F3, 7. pont): ha a kiosztott rész TÖRT maradékot is visz,
/// a vele közös fizikai raklapon utazó, még sehova ki nem osztott csoporttársak ugyanerre a kamionra kerülnek.
/// Egy raklapot nem lehet kettévágni, tehát külön felrakni sem. A RÉSZBEN már kiosztott testvért nem bántjuk:
/// annak a megbontása admin-döntés (bontás), nem mellékhatás.</para>
/// <para>A csoport EGYBEN kerül fel (ADR 0004-M9F3, 7. pont): a vele közös fizikai raklapon utazó, még sehova
/// ki nem osztott csoporttársak ugyanerre a kamionra kerülnek. Egy raklapot nem lehet kettévágni, tehát külön
/// felrakni sem. A RÉSZBEN már kiosztott testvért nem bántjuk: annak a megbontása admin-döntés (bontás), nem
/// mellékhatás.</para>
public async Task<ShippingItemToShipping> AssignShippingItemAsync(int shippingItemId, int shippingId, int quantity, double pallets)
{
var allocation = await AssignShippingItemCoreAsync(shippingItemId, shippingId, quantity, pallets);
if (ShippingCapacityHelper.RemainderPallets(allocation.Pallets) <= 0) return allocation;
// A kaszkádot a CSOPORTTAGSÁG váltja ki, nem a felrakott rész törtje. Az ADR 7. pontja „egy csoporttag
// áthelyezését" mondja, megkötés nélkül — a törtre szűkítés iránytól függővé tette a viselkedést: a teli
// raklap felől indítva a csoporttárs a felrakón maradt, a tört felől indítva vele ment (X3V8).
var shippingItem = await ShippingItems.GetByIdAsync(shippingItemId, false);
if (shippingItem?.PalletGroupNo == null) return allocation;
if (shippingItem?.PalletGroupId == null) return allocation;
foreach (var sibling in await GetRemainderGroupSiblingsAsync(shippingItem))
{
@@ -1181,17 +1193,24 @@ public class FruitBankDbContext : MgDbContextBase,
$"Túl-osztás: a(z) #{shippingItemId} tételből legfeljebb {remainingQuantity} rekesz / {remainingPallets:0.###} raklap " +
$"osztható még ki (kért: {quantity} rekesz / {pallets:0.###} raklap).");
// A maradék-csoport EGY fizikai raklapon utazik, amit nem lehet két kamionra tenni (ADR 0004-M9F3, 7. pont).
// Az egész raklapok szabadon oszthatók — csak a TÖRT rész kötött.
if (ShippingCapacityHelper.RemainderPallets(pallets) > 0)
// A csoport EGY fizikai raklapon utazik, amit nem lehet két kamionra tenni (ADR 0004-M9F3, 7. pont).
// A tagságra kulcsol, nem a tört részre: a törtre szűkítve egy egész raklapszámú csoporttag (teli raklap,
// aminek a tetején utazik a többi) némán másik kamionra kerülhetett, és a közös raklap kettévált (X3V8).
if (shippingItem.PalletGroupId is { } palletGroupId)
{
var groupShippingId = await GetRemainderGroupTruckAsync(shippingItem);
if (groupShippingId.HasValue && groupShippingId.Value != shippingId)
{
// Az ADMIN sorszámát idézzük, nem a surrogate azonosítót: „a 7. összevont raklapon" érthető,
// „a #4712. összevont raklapon" nem. Hiányzó csoport-sornál az azonosító a tartalék.
var group = await ShippingItemPalletGroups.GetByIdAsync(palletGroupId);
throw new InvalidOperationException(
$"A(z) #{shippingItemId} tétel maradéka a(z) {shippingItem.PalletGroupNo}. összevont raklapon utazik, " +
$"A(z) #{shippingItemId} tétel a(z) {group?.GroupNo ?? palletGroupId}. összevont raklapon utazik, " +
$"ami már a(z) #{groupShippingId.Value} kamionon van. Egy fizikai raklap nem kerülhet két kamionra — " +
"vagy oda oszd, vagy előbb húzd át az egész csoportot.");
}
}
var allocation = existing.FirstOrDefault(a => a.ShippingId == shippingId);
@@ -1274,20 +1293,18 @@ public class FruitBankDbContext : MgDbContextBase,
var sourceShippingId = before.ShippingId;
var movedItem = await ShippingItems.GetByIdAsync(before.ShippingItemId, false);
var movesRemainder = ShippingCapacityHelper.RemainderPallets(before.Pallets) > 0;
var moved = await MoveAllocationCoreAsync(allocationId, targetShippingId);
if (!movesRemainder || movedItem?.PalletGroupNo == null || sourceShippingId == targetShippingId) return moved;
// Csoporttagság a kiváltó feltétel, nem a mozgatott allocation törtje (X3V8).
if (movedItem?.PalletGroupId == null || sourceShippingId == targetShippingId) return moved;
foreach (var sibling in await GetRemainderGroupSiblingsAsync(movedItem))
{
var allocations = await ShippingItemAllocations.GetAllByShippingItemIdAsync(sibling.Id, false).ToListAsync();
// Csak a maradékot hordozó, és csak az EREDETI kamionon lévő testvér-allocation jön — a másik kamionon
// lévő egész raklapjaikhoz semmi közünk.
foreach (var companion in allocations.Where(a => a.ShippingId == sourceShippingId
&& ShippingCapacityHelper.RemainderPallets(a.Pallets) > 0))
// Csak az EREDETI kamionon lévő testvér-allocation jön — a másik kamionon lévő részükhöz semmi közünk.
foreach (var companion in allocations.Where(a => a.ShippingId == sourceShippingId))
await MoveAllocationCoreAsync(companion.Id, targetShippingId);
}
@@ -1364,18 +1381,16 @@ public class FruitBankDbContext : MgDbContextBase,
await UnlinkPalletsFromAllocationAsync(allocation.Id);
await ShippingItemAllocations.DeleteAsync(allocation, false);
// A maradék-csoport egyben mozog — levételnél is (ADR 0004-M9F3, 7. pont): ha a közös fizikai raklap lekerül
// a kamionról, a rajta utazó csoporttársak is vele mennek. Külön-külön levenni őket fizikailag értelmetlen.
if (ShippingCapacityHelper.RemainderPallets(allocation.Pallets) <= 0) return;
// A csoport egyben mozog — levételnél is (ADR 0004-M9F3, 7. pont): ha a közös fizikai raklap lekerül a
// kamionról, a rajta utazó csoporttársak is vele mennek. Külön-külön levenni őket fizikailag értelmetlen.
// A kiváltó feltétel a csoporttagság, nem a levett allocation törtje (X3V8).
var shippingItem = await ShippingItems.GetByIdAsync(allocation.ShippingItemId, false);
if (shippingItem?.PalletGroupNo == null) return;
if (shippingItem?.PalletGroupId == null) return;
foreach (var sibling in await GetRemainderGroupSiblingsAsync(shippingItem))
{
var companions = (await ShippingItemAllocations.GetAllByShippingItemIdAsync(sibling.Id, false).ToListAsync())
.Where(a => a.ShippingId == allocation.ShippingId
&& ShippingCapacityHelper.RemainderPallets(a.Pallets) > 0);
.Where(a => a.ShippingId == allocation.ShippingId);
foreach (var companion in companions)
{
@@ -30,6 +30,7 @@ public class ShippingDocumentDbTable : MgDbTableBase<ShippingDocument>
.LoadWith(sd => sd.ShippingItems).ThenLoad(si => si.Pallet)
.LoadWith(sd => sd.ShippingItems).ThenLoad(si => si.ProductDto).ThenLoad(prod => prod.GenericAttributes)
.LoadWith(sd => sd.ShippingItems).ThenLoad(si => si.ShippingItemPallets)
.LoadWith(sd => sd.ShippingItems).ThenLoad(si => si.PalletGroup)
.LoadWith(sd => sd.ShippingDocumentToFiles).ThenLoad(sdtof => sdtof.ShippingDocumentFile)
.LoadWith(sd => sd.Partner)
: GetAll();
@@ -43,7 +43,8 @@ public class ShippingItemDbTable : MgDbTableBase<ShippingItem>
.LoadWith(si => si.ShippingItemPallets)
.LoadWith(si => si.ProductDto).ThenLoad(prod => prod.GenericAttributes)
.LoadWith(si => si.Pallet)
: GetAll().LoadWith(si => si.ShippingItemPallets).LoadWith(si => si.ProductDto).ThenLoad(prod => prod.GenericAttributes).LoadWith(si => si.Pallet);
.LoadWith(si => si.PalletGroup)
: GetAll().LoadWith(si => si.ShippingItemPallets).LoadWith(si => si.ProductDto).ThenLoad(prod => prod.GenericAttributes).LoadWith(si => si.Pallet).LoadWith(si => si.PalletGroup);
}
public IQueryable<ShippingItem> GetAllNotMeasured(bool loadRelations)
@@ -0,0 +1,52 @@
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;
/// <summary>
/// Raklap-csoportok: a dokumentumon belül közös fizikai raklapokon utazó tételek, a csoport ADMIN-ADTA
/// raklapszámával (ADR FBANKAPP 0006-K4T9).
/// </summary>
/// <remarks>
/// A csoport foglaltsága nem levezethető a tételekből — a raklap korlátja a magasság, nem az alapterület —,
/// ezért a szám ezen a soron él, nem a tételeken. Tételre írva ugyanaz az érték több sorban állna, és semmi
/// nem tartaná őket szinkronban.
/// </remarks>
public class ShippingItemPalletGroupDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings)
: MgDbTableBase<ShippingItemPalletGroup>(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
{
public override IQueryable<ShippingItemPalletGroup> GetAll() => base.GetAll();
/// <summary>Egy dokumentum csoportjai.</summary>
public IQueryable<ShippingItemPalletGroup> GetAllByShippingDocumentId(int shippingDocumentId)
=> GetAll().Where(g => g.ShippingDocumentId == shippingDocumentId);
/// <summary>A dokumentumon belüli sorszám szerint. A pár EGYEDI (unique index), ezért legfeljebb egy sor.</summary>
public Task<ShippingItemPalletGroup> GetByDocumentAndNoAsync(int shippingDocumentId, int groupNo)
=> GetAll().FirstOrDefaultAsync(g => g.ShippingDocumentId == shippingDocumentId && g.GroupNo == groupNo);
public Task<ShippingItemPalletGroup> GetByIdAsync(int id)
=> GetAll().FirstOrDefaultAsync(g => g.Id == id);
/// <summary>A megadott csoportok raklapszáma azonosító szerint — a tervező foglaltság-számításához.</summary>
/// <remarks>
/// A tervező a rakomány MINDEN sorához odateszi a csoportja számát, mert a kapacitás-képlet soronként kap
/// adatot. Egy sorozatnyi külön lekérdezés helyett egyben hozzuk le őket.
/// </remarks>
public async Task<Dictionary<int, int>> GetPalletsByIdsAsync(IEnumerable<int> groupIds)
{
var ids = groupIds?.Distinct().ToList() ?? [];
if (ids.Count == 0) return [];
// A ToDictionaryAsync a nop és a LinqToDB bővítménye közt kétértelmű ebben a névtér-készletben,
// ezért a LinqToDB-t nevezzük meg — a lekérdezés annak a szolgáltatóján fut.
return await AsyncExtensions.ToDictionaryAsync(
GetAll().Where(g => ids.Contains(g.Id)), g => g.Id, g => g.Pallets);
}
}
@@ -191,7 +191,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Factories
var genericAttributes = orderDto.GenericAttributes;
if(genericAttributes != null)
{
var innvoiceTechIdAttribute = genericAttributes.FirstOrDefault(ga => ga.Key == "InnVoiceOrderTechId");
var innvoiceTechIdAttribute = genericAttributes.FirstOrDefault(ga => ga.Key == FruitBankPluginConst.InnVoiceOrderTechIdAttribute);
if (innvoiceTechIdAttribute != null)
{
orderModelExtended.InnvoiceTechId = innvoiceTechIdAttribute.Value;
@@ -26,6 +26,19 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
/// </summary>
public const int PreOrderConversionWindowDays = 4;
// ── Sales VAT ────────────────────────────────────────────────────────────
/// <summary>The VAT rate our outgoing invoices are issued with.</summary>
/// <remarks>
/// The rate is stated here rather than read from the product's nop tax category, because that route
/// yields nothing today: the tax service returns the price unchanged for these products, which is why
/// <c>OrderItem.PriceExclTax</c> equals <c>PriceInclTax</c> on almost every row. A report deriving VAT
/// from those two columns reads zero.
/// <para>Whoever gives a product a different rate must replace this constant with a per-product lookup:
/// the outgoing invoice (<c>InnVoiceOrderController</c>, <c>AfaSzoveg</c>) and the VAT report both state
/// one rate for every line, so a second rate silently produces a wrong figure on both.</para>
/// </remarks>
public const decimal OutgoingVatRate = 0.27m;
// ── Customer generic-attribute keys (store-agnostic, StoreId 0) ──────────
/// <summary>Bool flag: the customer is subject to EKÁER reporting.</summary>
public const string IsEkaerAttribute = "isEkaer";
@@ -44,6 +57,19 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
/// <summary>License plate (rendszám) of the vehicle picking up the order. Raw; the NAV mapper normalizes.</summary>
public const string OrderLicensePlateAttribute = "LicensePlate";
// ── InnVoice submission markers, written when an order is submitted ──────
// These say the order was SUBMITTED, never that an invoice exists: the outbound direction submits an
// order, not an invoice. A reader that treats the marker as proof of invoicing states more than the
// data holds.
/// <summary>InnVoice technical id of the submitted order.</summary>
public const string InnVoiceOrderTechIdAttribute = "InnVoiceOrderTechId";
/// <summary>InnVoice table id of the submitted order — the marker a report tests for.</summary>
public const string InnVoiceOrderTableIdAttribute = "InnVoiceOrderTableId";
/// <summary>Print (PDF) link InnVoice returned for the submitted order.</summary>
public const string InnVoiceOrderPrintLinkAttribute = "InnVoiceOrderPrintLink";
// ── Plugin-owned table names (NOT shared with the Hybrid client) ─────────
// The shared entities' table names live in FruitBankConstClient (FruitBank.Common), because those
// entities go over SignalR. Cost and margin data is admin-only (ADR 0002-R7Q2), so both the entity
@@ -115,6 +115,7 @@ public class PluginNopStartup : INopStartup
services.AddScoped<ShippingItemDbTable>();
services.AddScoped<ShippingItemPalletDbTable>();
services.AddScoped<ShippingItemToShippingDbTable>();
services.AddScoped<ShippingItemPalletGroupDbTable>();
services.AddScoped<ShippingCostDbTable>();
services.AddScoped<CostConsumptionDbTable>();
services.AddScoped<PreOrderFulfilmentDbTable>();
@@ -37,6 +37,7 @@ public partial class NameCompatibility : INameCompatibility
{ typeof(ShippingDocumentToFiles), FruitBankConstClient.ShippingDocumentToFilesDbTableName},
{ typeof(ShippingItemToShipping), FruitBankConstClient.ShippingItemToShippingDbTableName},
{ typeof(ShippingItemPalletGroup), FruitBankConstClient.ShippingItemPalletGroupDbTableName},
{ typeof(StockQuantityHistoryDto), nameof(StockQuantityHistory)},
{ typeof(StockQuantityHistoryExt), FruitBankConstClient.StockQuantityHistoryExtDbTableName},
@@ -154,7 +154,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
Total: order.OrderTotal,
Audited: audited,
WeightOk: audited ? order.IsValidMeasuringValues() : null,
InvoiceCreated: audited ? order.GenericAttributes.Any(a => a.Key == "InnVoiceOrderTableId") : null,
InvoiceCreated: audited ? order.GenericAttributes.Any(a => a.Key == FruitBankPluginConst.InnVoiceOrderTableIdAttribute) : null,
Completed: audited ? order.OrderStatus == OrderStatus.Complete : null);
}).ToList();
@@ -1,4 +1,8 @@
using LinqToDB;
using System.Globalization;
using FruitBank.Common.Dtos;
using FruitBank.Common.Interfaces;
using LinqToDB;
using Nop.Core.Domain.Orders;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
@@ -15,33 +19,30 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
/// <para><b>A bejövő oldal a tükör.</b> A szállítmány-költségek (<c>fbShippingCost</c>) és a beszállítói
/// szállítólevelek NEM számítanak bele: azok is az InnVoice-on át jönnek, és kétszer venni őket
/// duplázás lenne.</para>
/// <para><b>A kimenő oldal hónapja az ÁTVÉTEL</b> (<c>OrderDto.DateOfReceipt</c>), nem a rendelés kelte és
/// nem a lezárás pillanata — az árrés-kimutatás ezért szándékosan más naptárt követ (ADR 0004-K9T3).</para>
/// <para><b>A kimenő oldal a bruttóból számol</b>, egyetlen kulccsal
/// (<see cref="FruitBankPluginConst.OutgoingVatRate"/>) — ugyanazzal, amivel a számla kimegy. A rendelés-tétel
/// nettó oszlopa erre nem alkalmas, az ott áll, hogy miért.</para>
/// <para>A kimutatás teljes szabálykészlete: <c>docs/INNVOICE/INNVOICE_INCOMING.md</c> → Monthly VAT report.</para>
/// </remarks>
public class VatReportService(
IncomingInvoiceDbTable incomingInvoices,
IncomingInvoiceItemDbTable incomingInvoiceItems,
CostConsumptionDbTable costConsumptions,
FruitBankDbContext dbContext)
{
/// <summary>Ennél korábbi vagy ennél későbbi teljesítés-dátum adathiba, nem valós hónap.</summary>
private static readonly DateTime PlausibleFrom = new(2015, 1, 1);
/// <summary>
/// A kimutatás alsó határa: ennél korábbi időszak nem kérdezhető le.
/// </summary>
/// <remarks>
/// A bejövő oldal ugyan öt évre visszamenőleg megvan a tükörben, a KIMENŐ oldal viszont nem: az a
/// fogyasztás-sorokra épül, azok pedig csak az árrés-funkció élesítése óta keletkeznek. Egy korábbi
/// hónapra a riport valós bejövő áfát és nulla kimenőt mutatna — az egyenleg nem hiányzó áfát jelezne,
/// hanem hiányzó adatot. Ezt nem magyarázzuk el minden sorban, hanem el sem engedjük odáig.
/// </remarks>
public static readonly DateTime DataAvailableFrom = new(2026, 8, 1);
/// <summary>A HUF megnevezései az InnVoice-ban. A tükör azt tárolja, amit a forrás ad ("Ft").</summary>
private static readonly HashSet<string> HufCodes = new(StringComparer.OrdinalIgnoreCase) { "Ft", "HUF" };
/// <summary>A tételes lista felső határa — egy éves időszak több ezer sort adna.</summary>
private const int InvoiceListLimit = 500;
/// <summary>Ennyi id mehet egy IN-listába; a hosszabb lista tervezési és paraméter-korlátba ütközik.</summary>
private const int IdChunkSize = 1000;
public async Task<VatReportModel> CalculateAsync(DateTime fromInclusive, DateTime toExclusive,
VatReportInvoiceFilter? filter = null)
{
@@ -207,50 +208,115 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
private async Task AddOutgoingAsync(VatReportModel model, DateTime fromInclusive, DateTime toExclusive)
{
// A rendelés lezárásának pillanata a fogyasztás-sor Created mezője — a nop rendelés-dátumai közül
// egyik sem jelöli a lezárást (ADR 0002-R7Q2, 10. pont). Így a havi bontás egyezik az árrés-riportéval.
var consumptions = await costConsumptions.GetAllByPeriod(fromInclusive, toExclusive, false)
.Where(consumption => consumption.ConsumerTypeId == (int)CostConsumerType.OrderItem)
.Select(consumption => new { consumption.ConsumerId, consumption.Created })
// Az átvétel időpontja GenericAttribute-ban, STRINGKÉNT áll, tehát a hónap SQL-ből nem szűrhető
// (ADR 0004-K9T3, 3. és 4. pont). Ezért a kulcs összes sorát behozzuk — rendelésenként egy sor, két
// oszlop —, és a hónapot memóriában döntjük el. Dátum szerinti SQL-előszűrő NEM kerülhet ide: egy
// rossz határ némán dobna el rendeléseket, és pont az ilyen néma hiány ellen készült a kimutatás.
var pickupValues = await dbContext.GenericAttributes.Table
.Where(attribute => attribute.KeyGroup == nameof(Order)
&& attribute.Key == nameof(IOrderDto.DateOfReceipt))
.Select(attribute => new { attribute.EntityId, attribute.Value })
.ToListAsync();
if (consumptions.Count == 0) return;
// ⚠️ Rendelésenként EGY érték. Az attribútum bolt-hatókörrel íródik, tehát két bolt két sort adna
// ugyanarra a rendelésre — a rendelés kétszer esne bele a hónapba, és a kimenő áfa csendben duplázna.
var pickupByOrder = pickupValues
.GroupBy(pickup => pickup.EntityId)
.ToDictionary(group => group.Key, group => group.Last().Value);
// ⚠️ EGY rendelés-tétel TÖBB rétegből is fogyhat, tehát több fogyasztás-sora van. A tétel áfáját
// soronként összegezve annyiszor számolnánk, ahány rétegből fogyott — ezért DISZTINKT tétel-id.
var itemIdsByMonth = consumptions
.GroupBy(consumption => (consumption.Created.Year, consumption.Created.Month))
.ToDictionary(g => g.Key, g => g.Select(x => x.ConsumerId).Distinct().ToList());
var allItemIds = itemIdsByMonth.Values.SelectMany(ids => ids).Distinct().ToList();
var orderItems = (await dbContext.OrderItemDtos.GetAll()
.Where(item => allItemIds.Contains(item.Id))
.Select(item => new { item.Id, item.PriceExclTax, item.PriceInclTax })
// ⚠️ Az adatbázisnak SOSEM adunk át hosszú id-listát szűrőnek. A rendelés-történet minden datált sora
// egy-egy paraméter lenne egy NOT IN-ben, és az a lekérdezés időtúllépésbe fut (mérve: 2026-09-01).
// Az adatbázistól oszlopokat kérünk, a halmaz-műveletek memóriában futnak.
var completedOrderIds = (await CompletedOrders()
.Select(order => order.Id)
.ToListAsync())
.ToDictionary(item => item.Id);
.ToHashSet();
foreach (var (period, itemIds) in itemIdsByMonth)
var orderIdsByMonth = new Dictionary<(int Year, int Month), List<int>>();
foreach (var (orderId, pickupValue) in pickupByOrder)
{
if (!completedOrderIds.Contains(orderId)) continue;
// ⚠️ INVARIÁNS kultúra. A mező "MM/dd/yyyy HH:mm:ss" alakban íródik; magyar locale-on a
// 08/05/2026 május 8-nak olvasódna, és a hiba a hónap 112. napjain néma.
if (!DateTime.TryParse(pickupValue, CultureInfo.InvariantCulture,
DateTimeStyles.None, out var pickedUpAt))
{
model.UnparsablePickupDate++;
continue;
}
if (pickedUpAt < fromInclusive || pickedUpAt >= toExclusive) continue;
var period = (pickedUpAt.Year, pickedUpAt.Month);
if (!orderIdsByMonth.TryGetValue(period, out var ids))
orderIdsByMonth[period] = ids = [];
ids.Add(orderId);
}
// Átvételi időpont nélküli lezárt rendelés egyetlen hónapba sem esik bele — ugyanaz a hiány, amit a
// bejövő oldalon a teljesítés-dátum nélküli számla jelent, tehát ugyanúgy számolni kell.
model.WithoutPickupDate = completedOrderIds.Count(orderId => !pickupByOrder.ContainsKey(orderId));
if (orderIdsByMonth.Count == 0) return;
// Egy hónap ~1200 rendelés. Ennyi id már átadható szűrőnek, de DARABOLVA: a lekérdezés-terv és a
// paraméter-korlát is a lista hosszával romlik.
var grossByOrder = new Dictionary<int, decimal>();
foreach (var chunk in orderIdsByMonth.Values.SelectMany(ids => ids).Chunk(IdChunkSize))
{
var itemRows = await dbContext.OrderItemDtos.GetAll()
.Where(item => chunk.Contains(item.OrderId))
.Select(item => new { item.OrderId, item.PriceInclTax })
.ToListAsync();
foreach (var itemRow in itemRows)
grossByOrder[itemRow.OrderId] =
grossByOrder.GetValueOrDefault(itemRow.OrderId) + itemRow.PriceInclTax;
}
// Eladás = lezárt rendelés. A be nem küldött rendelés BENNE marad az összegben és külön számlálót
// kap: a hiányzó számla üzleti kivétel, nem szűrési szempont (ADR 0004-K9T3, 1. pont). A jelölő is
// szűrő-lista nélkül jön, egy oszlopban — a metszet memóriában.
var submittedIds = (await dbContext.GenericAttributes.Table
.Where(attribute => attribute.KeyGroup == nameof(Order)
&& attribute.Key == FruitBankPluginConst.InnVoiceOrderTableIdAttribute)
.Select(attribute => attribute.EntityId)
.ToListAsync())
.ToHashSet();
foreach (var (period, orderIds) in orderIdsByMonth)
{
var row = model.RowFor(new DateTime(period.Year, period.Month, 1));
foreach (var itemId in itemIds)
foreach (var orderId in orderIds)
{
if (!orderItems.TryGetValue(itemId, out var item))
{
// A rendelés-tétel eltűnt a fogyasztás-sor alól. Nem hallgatjuk el: a kimenő áfa
// enélkül csendben kevesebb lenne.
row.MissingOrderItemCount++;
continue;
}
// ⚠️ Az áfa a BRUTTÓBÓL áll elő, soha nem a PriceInclTax PriceExclTax különbségből. A nop
// adószolgáltatása ezekre a termékekre változatlanul adja vissza az árat
// (FruitBankOrderItemService), tehát a két oszlop a tételek túlnyomó részén AZONOS, és a
// különbség nullát ad — a kimutatás így a valós áfa fél százalékát mutatta. A PriceInclTax
// viszont megbízható: ezzel az egységárral megy ki a számla is (InnVoiceOrderController).
var grossAmount = grossByOrder.TryGetValue(orderId, out var gross) ? gross : 0m;
var netAmount = decimal.Round(
grossAmount / (1m + FruitBankPluginConst.OutgoingVatRate), 2);
row.OutgoingOrderItemCount++;
row.OutgoingNetAmount += item.PriceExclTax;
row.OutgoingVatAmount += item.PriceInclTax - item.PriceExclTax;
row.OutgoingOrderCount++;
row.OutgoingNetAmount += netAmount;
row.OutgoingVatAmount += grossAmount - netAmount;
if (!submittedIds.Contains(orderId)) row.NotSubmittedCount++;
}
}
}
/// <summary>A lezárt, nem törölt rendelések — az eladási oldal populációja.</summary>
private IQueryable<OrderDto> CompletedOrders()
=> dbContext.OrderDtos.GetAllByOrderStatus(OrderStatus.Complete, false)
.Where(order => !order.Deleted);
// ── Ami egyik hónapba sem esik bele ──────────────────────────────────────
private async Task AddAnomaliesAsync(VatReportModel model)
@@ -288,11 +354,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
public DateTime FromInclusive { get; set; }
public DateTime ToExclusive { get; set; }
/// <summary>A lekérdezhető legkorábbi nap — a felület ezt teszi a dátummezők alsó határának.</summary>
public DateTime DataAvailableFrom => VatReportService.DataAvailableFrom;
/// <summary>A kért kezdő dátum korábbi volt az adathatárnál, ezért felhúztuk.</summary>
public bool FromClamped { get; set; }
/// <summary>A kimenő oldal áfakulcsa — a felület kiírja, mert a számból nem derül ki.</summary>
public decimal OutgoingVatRate => FruitBankPluginConst.OutgoingVatRate;
/// <summary>Mikor frissült utoljára a tükör az InnVoice-ból; a riport nem szinkronizál magától.</summary>
public DateTime? MirrorSyncedAt { get; set; }
@@ -329,6 +392,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
/// <summary>Értelmetlen teljesítés-dátumú számlák (elgépelt évszám) — szintén kimaradnak minden hónapból.</summary>
public int ImplausiblePerformanceDate { get; set; }
/// <summary>Átvételi időpont nélküli lezárt rendelések — egyik hónapba sem esnek bele.</summary>
public int WithoutPickupDate { get; set; }
/// <summary>Értelmezhetetlen átvételi időpont — a tárolt szöveg nem dátum, tehát a rendelés kimarad.</summary>
public int UnparsablePickupDate { get; set; }
public VatReportRow RowFor(DateTime date)
{
var row = Rows.FirstOrDefault(r => r.Year == date.Year && r.Month == date.Month);
@@ -382,12 +451,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
public int SetAsideCount { get; set; }
// ── Kimenő ──
public int OutgoingOrderItemCount { get; set; }
public int OutgoingOrderCount { get; set; }
public decimal OutgoingNetAmount { get; set; }
public decimal OutgoingVatAmount { get; set; }
/// <summary>Fogyasztás-sor, aminek a rendelés-tétele nem található.</summary>
public int MissingOrderItemCount { get; set; }
/// <summary>Lezárt rendelés, ami nem ment ki az InnVoice-ba — benne van az összegben, de látszik.</summary>
public int NotSubmittedCount { get; set; }
/// <summary>Devizánkénti bontás, saját pénznemben.</summary>
public List<VatCurrencyRow> CurrencyRows { get; set; } = [];
@@ -35,8 +35,13 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
_logger = logger;
}
private string ApiKey => _settings.ZaiApiKey
?? throw new InvalidOperationException("ZAI API kulcs nincs konfigurálva (FruitBankSettings.ZaiApiKey).");
// A beállítás alapértéke string.Empty, sosem null — az üresre kell vizsgálni, különben az őr nem fog,
// és a kulcs nélküli kérés a szolgáltató "nincs kulcs" hibájaként jön vissza, nem a hiányzó beállításként.
private string ApiKey => string.IsNullOrWhiteSpace(_settings.ZaiApiKey)
? throw new InvalidOperationException(
"ZAI API kulcs nincs konfigurálva (FruitBankSettings.ZaiApiKey). " +
"Admin → Bővítmények → FruitBank plugin → Konfigurálás → Z.ai GLM-OCR.")
: _settings.ZaiApiKey.Trim();
private string Model => string.IsNullOrWhiteSpace(_settings.ZaiModel)
? DefaultModel
+25 -1
View File
@@ -15,12 +15,36 @@ Plugin configuration stored in nopCommerce settings table (`ISettings`). Retriev
| `OpenAIModelName` | string | OpenAI model name |
| `OpenAIApiBaseUrl` | string | OpenAI API base URL |
| `MaxTokens` | int | Max response tokens |
| `Temperature` | double | Sampling temperature |
| `Temperature` | decimal | Sampling temperature |
| `RequestTimeoutSeconds` | int | HTTP timeout |
| `IsEnabled` | bool | Plugin enabled flag |
| `ZaiApiKey` | string | Z.ai API key for GLM-OCR document processing |
| `ZaiModel` | string | Z.ai OCR model name; default `glm-ocr` |
| `OrderDraftApiKey` | string | Shared key of the external order-draft endpoint (`X-FruitBank-ApiKey` header) |
| `InnVoiceCompanyName` | string | InnVoice registration name — the `regnev` path segment |
| `InnVoiceUsername` | string | InnVoice API user (HTTP Basic) |
| `InnVoicePassword` | string | InnVoice API password (HTTP Basic) |
| `InnVoiceBaseUrl` | string | InnVoice API base URL; empty falls back to `https://api.innvoice.hu` |
Configured via admin UI: `FruitBankPluginAdminController` → Configure view.
### Saving secrets
`ApiKey`, `OpenAIApiKey`, `ZaiApiKey` and `InnVoicePassword` are `type="password"` inputs, and a password input
is never rendered with its stored value. An untouched field posts back empty, so the POST handler keeps the
stored secret instead of writing the empty string (`FruitBankPluginAdminController.KeepStoredWhenBlank`).
Consequences:
- A secret cannot be blanked through the form. Clear it in the `Setting` table.
- Every secret input carries `autocomplete="new-password"`. Without it the browser password manager treats the
field as a login box and fills the admin's own password into it, which Save then stores in clear text.
- A non-secret field is still overwritten by whatever posts back — the GET handler must fill every one of them
into the model, or Save wipes it.
The four InnVoice values are read through `InnVoiceCredentials.Require`, which **throws naming the missing
setting** rather than returning an empty string: an empty user name produces a 401, and a 401 reads as a network
or password problem, not as an unconfigured plugin. Details: [`INNVOICE/README.md`](INNVOICE/README.md) → Access.
## IAIAPIService Interface
Common contract for AI providers.
File diff suppressed because one or more lines are too long
@@ -7,17 +7,21 @@
## MGFBANKPLUG-INNV-T-M6K3: Scheduled incremental sync for the incoming mirror
**Status:** Open · **Priority:** P2 · **Type:** T · **2026-08-26**
**Status:** Closed (2026-08-27) · **Priority:** P2 · **Type:** T · **2026-08-26**
The mirror only refreshes when someone presses "Szinkronizálás" on admin → *Bejövő számlák*. The VAT report reads a mirror that may be days old; it states its age in the header, which makes the staleness visible but not smaller.
**Not on page load.** The id-less budget is 20 requests per hour and overrun degrades silently, so a sync per report open would exhaust it unpredictably and then serve incomplete data without saying so.
### Steps
### Resolution
- [ ] A nopCommerce `IScheduleTask` implementation calling `IncomingInvoiceSyncService.SyncAsync(false)`. **The plugin has none today** — this is the first, so registration on install and removal on uninstall are part of the work.
- [ ] Hourly period: 24 requests a day out of 480, and the report is never more than an hour behind.
- [ ] Failures reach the log with the counts the sync already produces (`FetchedCount`, `InsertedCount`, `UpdatedCount`, `SkippedCount`, `FailedCount`); a scheduled task that fails quietly is worse than the manual button.
**What:** not built — won't fix.
**Why (üzleti, Adam, 2026-08-27):** an hourly schedule is *worse* than the button, not better. Waiting up to an hour for an invoice to appear beats pressing a button only if the user trusts the schedule — and they cannot, because nothing on the screen can tell them whether the latest invoice is already in. So the admin would press the button anyway, and the schedule would spend quota on top of that.
The staleness this entry was opened against is therefore handled by making the button's effect legible, not by removing the button: the VAT report header states when the mirror last synced and that the page does not fetch on its own.
**Docs:** no doc change — [`INNVOICE_INCOMING.md`](INNVOICE_INCOMING.md) → *Sync* already states that nothing runs the sync automatically. The forward pointer to this entry is removed there.
---
@@ -35,3 +39,69 @@ The dead invoice JS in `Views/OrderAttributes.cshtml` is groundwork for this and
- [ ] Confirm `Invoice/CreateInvoice` creates the invoice in draft status.
- [ ] Decide the fate of the `InnVoiceOrder/CreateOrder` path — keep, deprecate or remove.
- [ ] Remove the page-load `GetInvoiceStatus` call if the final design does not need it.
---
## MGFBANKPLUG-INNV-T-P4H7: Az első feltöltés legyen túlélhető egy friss adatbázison
**Status:** Open · **Priority:** P2 · **Type:** T · **2026-08-27**
Éles hiba (PROD, 2026-08-27): a „Teljes újraszinkron" `HttpClient.Timeout of 100 seconds` hibával állt le, és
egyetlen sor sem jött létre. **A kvótából viszont fogyott** — a kérés kiment, mi adtuk fel az olvasást.
**Miért nem jött elő DEV-en:** a `HttpClient.Timeout` csak a LETÖLTÉST méri; a `GetAsync` a teljes választ
bepufferelve tér vissza, és az óra ott megáll. A 25 perc, amit DEV-en láttunk, az XML feldolgozása és a ~6551 sor
beírása volt — az már nem a `HttpClient` dolga. PROD-on maga a letöltés lépte túl a 100 másodpercet; hogy hálózat,
szolgáltatói terhelés vagy adatmennyiség miatt, azt nem mértük.
**Kikerülve (2026-08-27):** a DEV-tükör átmásolva PROD-ba, [`INNVOICE_incoming_copy_dev_to_prod.sql`](INNVOICE_incoming_copy_dev_to_prod.sql).
A vízjel is átjött, tehát a következő szinkron már csak a maradékot hozza. Ez **megkerülés, nem javítás**: a
következő friss adatbázison ugyanide jutnánk.
### Lépések
- [ ] **Időkorlát.** Az `InnVoiceApiService` csupasz `new HttpClient()`-et használ ([43. sor](../../Services/InnVoiceApiService.cs)),
tehát 100 s. A bejövő számlák lekérdezéséhez ez kevés — bőséges korlát kell (~10 perc).
- [ ] **Dátumtól induló első feltöltés.** Ma üres tükörnél a `SyncAsync` mindig a paraméter nélküli teljes
lekérdezést hívja, ami a **30 naponta 10-es** keretből költ. Az `updatedtime` út id nélküli, tehát a
**20/óra** keretből megy, és egy dátumtól kérve sokkal kevesebb adatot hoz. A szinkron lapra egy dátummező:
üres tükörnél onnantól tölt. A „Teljes újraszinkron" megmarad menekülőútnak.
*Az ÁFA-kimutatás alsó határa 2026-08-01, tehát ennél korábbi számla nem is jelenik meg benne.*
- [ ] **A hibaüzenet félrevezet.** „A bejövő számlák válaszának feldolgozása nem sikerült: …" — pedig a letöltés
bukott el. A `catch` a parse-ág köré van húzva, a timeout is oda esik be. A két eset szétválasztandó,
különben a következő vizsgálat is rossz helyen kezd keresni.
- [ ] **Mérés.** A szinkron logolja külön a letöltés és a feldolgozás idejét. Enélkül a „miért lassabb PROD-on"
kérdésre továbbra is csak tippelni lehet.
- [ ] **A „Nyers válasz" gomb is a teljes lekérdezést indítja üres tükörnél** (a vízjel `null`), tehát a
diagnosztikának szánt gomb a legszűkösebb kvótából költ. Jelezni kell a felületen, vagy dátumot kérni hozzá.
---
## MGFBANKPLUG-INNV-T-R6D2: The VAT report's outgoing side moves from cost-consumption rows to completed orders
**Status:** InProgress · **Priority:** P1 · **Type:** T · **2026-08-31**
The anchor entry of [ADR 0004-K9T3](../adr/0004-K9T3-vat-report-outgoing-from-orders.md). This entry carries the
implementation status; the decision, its alternatives and its consequences stay in the record.
Measured on PROD (2026-08-31): the report showed 126 317 060 Ft gross for 2026-08 against 309 693 697 Ft of
completed orders, because the outgoing side only sees orders that have a cost-consumption row, and those begin
around 2026-08-20.
### Steps
- [x] **Read orders, not consumption rows.** `VatReportService.AddOutgoingAsync` takes every `Complete`,
non-deleted order instead of `CostConsumptionDbTable.GetAllByPeriod`. The order item totals stay the
revenue source; the VAT stays derived from the gross.
- [x] **Group by `DateOfReceipt`**, parsed with invariant culture from the `GenericAttribute` string. Not
`DateOfReceiptOrCreated` — the fallback would hide a missing date.
- [x] **Anomaly counters, with the numbers on the page**: completed order without a pickup date; completed order
with no `InnVoiceOrderTableId`; pickup date that does not parse.
- [x] **Drop `DataAvailableFrom`** and the header sentence explaining the floor; the date pickers lose their
lower bound.
- [x] **The InnVoice attribute keys** (`InnVoiceOrderTechId`, `InnVoiceOrderTableId`, `InnVoiceOrderPrintLink`)
move to `FruitBankPluginConst` before a second reader copies the literals.
- [ ] **Verify against the month already measured**: 2026-08 must come out at 309 693 697 Ft gross, and the
unsubmitted count must explain the distance from the invoiced figure.
- [x] **Docs** — [`INNVOICE_INCOMING.md`](INNVOICE_INCOMING.md) → *Monthly VAT report* states the new outgoing
rules as current fact, and the calendar difference against the margin report.
@@ -0,0 +1,174 @@
/* =============================================================================
BEJÖVŐ SZÁMLÁK a tükör átmásolása DEV-ből PROD-ba
-----------------------------------------------------------------------------
MIÉRT: a teljes InnVoice-lekérdezés kvótája 30 naponta 10, és PROD-on a
letöltés a HttpClient 100 másodperces korlátjába futott. DEV-en az adat már
megvan (~6551 számla), ugyanarról a bérlőről átmásolni olcsóbb és
biztonságosabb, mint még egyszer lekérni.
🛡 HASZNÁLAT sima lekérdezés-ablak, SQLCMD mód NEM kell.
Ctrl+H a fájlon belül:
FruitBank_DEV a FORRÁS (feltöltött tükör) adatbázisod neve
FruitBank_PROD a CÉL (üres tükör) adatbázisod neve
Ha a nevek amúgy is ezek, nincs mit átírni.
HA KORÁBBAN FÉLBESZAKADT EGY FUTÁS: nyiss ÚJ lekérdezés-ablakot. A
SET IDENTITY_INSERT session-szintű kapcsoló, amit a rollback NEM állít
vissza egy beragadt kapcsoló miatt a következő futás azzal áll meg, hogy
IDENTITY_INSERT is already ON for table ". Új session ezt megszünteti.
EGYSZERI, NEM IDEMPOTENS. Csak ÜRES cél-tükörre fut; ha már van benne sor,
szándékosan megáll. Ismételve duplikálna, és a havi ÁFA-összeg némán a
kétszerese lenne.
AZONOS SQL SERVER PÉLDÁNY kell (három részes névvel hivatkozunk a forrásra).
Külön szerver esetén lásd a fájl végét.
A LEGFONTOSABB OSZLOP: SourceUpdatedAt. Ennek a MAXIMUMA a növekményes
szinkron vízjele. Átmásolva a következő Szinkronizálás" onnan folytatja, és
csak a maradékot hozza le ez az egész művelet értelme. Enélkül a tükör
tele lenne, a vízjel üres, és a következő szinkron megint TELJES
lekérdezést indítana.
ELŐFELTÉTEL: a célon le kell futnia mind a három séma-scriptnek
INNVOICE_incoming_schema.sql, _exchangerate.sql, _attachment.sql.
Az ellenőrzés a törzsben van, és hiány esetén megáll.
============================================================================= */
SET NOCOUNT ON;
SET XACT_ABORT ON;
GO
USE FruitBank_PROD; -- ⬅ a CÉL adatbázis
GO
/* A DB_NAME() kiírása nem dísz: ha a fenti USE nem a szánt adatbázisra mutat,
minden további lépés máshol futna, és ez az egyetlen jel róla. */
SELECT DB_NAME() AS EbbeMasolunk;
GO
/* =============================================================================
ELŐFELTÉTELEK + MÁSOLÁS EGYETLEN batch-ben, szándékosan.
GO-val elválasztva a THROW csak a saját batch-ét állítaná meg, a másolás pedig
elindulna egy olyan adatbázison, amit épp elutasítottunk. Egy batch = az őr
tényleg őr.
============================================================================= */
BEGIN TRY
/* --- 0) Előfeltételek --------------------------------------------------- */
IF OBJECT_ID('dbo.fbIncomingInvoice', 'U') IS NULL
OR OBJECT_ID('dbo.fbIncomingInvoiceItem', 'U') IS NULL
THROW 50001, 'A tükör-táblák hiányoznak a CÉL adatbázisban. Futtasd előbb: INNVOICE_incoming_schema.sql', 1;
IF COL_LENGTH('dbo.fbIncomingInvoice', 'ExchangeRate') IS NULL
THROW 50002, 'Hiányzik az ExchangeRate oszlop. Futtasd: INNVOICE_incoming_exchangerate.sql', 1;
IF COL_LENGTH('dbo.fbIncomingInvoice', 'AttachmentUrl') IS NULL
THROW 50003, 'Hiányzik a csatolmány-oszlopcsoport. Futtasd: INNVOICE_incoming_attachment.sql', 1;
IF EXISTS (SELECT 1 FROM dbo.fbIncomingInvoice)
THROW 50004, 'A CÉL tükör NEM üres. Ez a script csak üres táblára fut — különben duplikálna.', 1;
BEGIN TRANSACTION;
/* --- 1) Számla-fejek ----------------------------------------------------
Az oszlopok tételesen fel vannak sorolva. SELECT * itt tilos: ha a két
adatbázisban eltér az oszlopsorrend, az értékek némán rossz oszlopba
kerülnének és egy elcsúszott nettó/áfa pár nem tűnik fel ránézésre. */
SET IDENTITY_INSERT dbo.fbIncomingInvoice ON;
INSERT INTO dbo.fbIncomingInvoice
(Id, InnVoiceTableId, InnVoiceUniqueId,
SupplierId, SupplierName, SupplierCountryCode, SupplierPostalCode, SupplierCity, SupplierStreet,
DocumentNumber, ReferenceNumber,
InvoiceDate, PerformanceDate, DueDate,
CurrencyCode, ExchangeRate,
NetAmount, VatAmount, GrossAmount, PaidAmount, OutstandingAmount,
PaymentMethod, Note, IsSetAside, NavLink,
AttachmentFileName, AttachmentUrl, AttachmentCount,
SourceUpdatedAt, SyncedAt, Created, Modified)
SELECT
Id, InnVoiceTableId, InnVoiceUniqueId,
SupplierId, SupplierName, SupplierCountryCode, SupplierPostalCode, SupplierCity, SupplierStreet,
DocumentNumber, ReferenceNumber,
InvoiceDate, PerformanceDate, DueDate,
CurrencyCode, ExchangeRate,
NetAmount, VatAmount, GrossAmount, PaidAmount, OutstandingAmount,
PaymentMethod, Note, IsSetAside, NavLink,
AttachmentFileName, AttachmentUrl, AttachmentCount,
SourceUpdatedAt, SyncedAt, Created, Modified
FROM FruitBank_DEV.dbo.fbIncomingInvoice; -- ⬅ a FORRÁS adatbázis
SET IDENTITY_INSERT dbo.fbIncomingInvoice OFF;
/* --- 2) Tételsorok ------------------------------------------------------
Ugyanabban a tranzakcióban: fej tételek nélkül hiányos kulcs-bontást adna
a kimutatásban (a NoItems" vödörbe esne), és ez csendes torzítás. */
SET IDENTITY_INSERT dbo.fbIncomingInvoiceItem ON;
INSERT INTO dbo.fbIncomingInvoiceItem
(Id, IncomingInvoiceId, InnVoiceTableId,
Name, ItemNumber, VtszSzj,
VatRate, VatText,
UnitPrice, IsGrossUnitPrice, Quantity, Unit, DiscountAmount, Note)
SELECT
Id, IncomingInvoiceId, InnVoiceTableId,
Name, ItemNumber, VtszSzj,
VatRate, VatText,
UnitPrice, IsGrossUnitPrice, Quantity, Unit, DiscountAmount, Note
FROM FruitBank_DEV.dbo.fbIncomingInvoiceItem; -- ⬅ a FORRÁS adatbázis
SET IDENTITY_INSERT dbo.fbIncomingInvoiceItem OFF;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
/* A SET IDENTITY_INSERT session-szintű: a rollback NEM állítja vissza. Kézzel
kapcsoljuk le mindkettőt, különben a következő futás azzal áll meg, hogy
already ON for table ", és a valódi hiba eltűnik e mögött.
Egy már kikapcsolt táblára az OFF ártalmatlan. */
SET IDENTITY_INSERT dbo.fbIncomingInvoice OFF;
SET IDENTITY_INSERT dbo.fbIncomingInvoiceItem OFF;
THROW;
END CATCH
GO
/* =============================================================================
ELLENŐRZÉS darabszámok és a vízjel
A Vizjel az, amitől a következő szinkron NEM teljes lekérdezést indít.
Ha itt NULL jelenik meg, a másolás nem hozta át a SourceUpdatedAt-et, és a
következő Szinkronizálás" újra a szűkös kvótából költene.
============================================================================= */
SELECT
(SELECT COUNT(*) FROM dbo.fbIncomingInvoice) AS Cel_Szamla,
(SELECT COUNT(*) FROM FruitBank_DEV.dbo.fbIncomingInvoice) AS Forras_Szamla,
(SELECT COUNT(*) FROM dbo.fbIncomingInvoiceItem) AS Cel_Tetel,
(SELECT COUNT(*) FROM FruitBank_DEV.dbo.fbIncomingInvoiceItem) AS Forras_Tetel,
(SELECT MAX(SourceUpdatedAt) FROM dbo.fbIncomingInvoice) AS Vizjel,
(SELECT COUNT(*) FROM dbo.fbIncomingInvoice WHERE PerformanceDate IS NULL) AS TeljesitesDatumNelkul;
GO
/* Árva tétel nem maradhat: 0-nak kell lennie. */
SELECT COUNT(*) AS ArvaTetel
FROM dbo.fbIncomingInvoiceItem i
WHERE NOT EXISTS (SELECT 1 FROM dbo.fbIncomingInvoice f WHERE f.Id = i.IncomingInvoiceId);
GO
/* =============================================================================
HA A CÉL KÜLÖN SZERVEREN VAN
-----------------------------------------------------------------------------
A három részes név (`FruitBank_DEV.dbo....`) csak azonos példányon működik.
Külön szervernél a két tábla adatát az SSMS Tasks Export Data" varázslójával
vidd át (forrás: DEV, cél: PROD), a fenti két tábla kijelölésével, és a cél
oldalon bekapcsolt Enable identity insert" opcióval. A tételeket a fejek UTÁN,
különben az idegen kulcs elhasal.
Utána az ellenőrző lekérdezést futtasd le a célon az mondja meg, hogy a
darabszámok egyeznek-e, és hogy a vízjel megvan-e.
============================================================================= */
@@ -21,6 +21,7 @@ InnVoice is the external invoicing system. Two independent directions share one
| `INNVOICE_incoming_schema.sql` | runbook — creates the mirror tables |
| `INNVOICE_incoming_exchangerate.sql` | runbook — adds `ExchangeRate` to the mirror |
| `INNVOICE_incoming_attachment.sql` | runbook — adds the attachment columns to the mirror |
| `INNVOICE_incoming_copy_dev_to_prod.sql` | one-off — copies a filled mirror between databases, watermark included |
The three `.sql` files are runbooks: idempotent, re-runnable, `USE FruitBank_DEV` at the top — rewrite the `USE` for PROD.
@@ -94,6 +94,7 @@ Hubs registered (mindkettő WebSockets-only endpoint):
| `GetShippingItemPallets` / `AddShippingItemPallet` / `UpdateShippingItemPallet` | Pallet CRUD |
| `AddOrUpdateMeasuredShippingItemPallet` | Measurement + average weight update + cascade |
| `GetShippingDocuments` / `AddShippingDocument` | Document operations, updates product costs on add |
| `GetShippingDocumentFileContent(filesId)` | The attached PDF/image as bytes, for the Hybrid app's own viewer. Delivered over SignalR rather than by URL: the MAUI client has no nop admin cookie, so a link would 401. |
| `ProcessAndSaveFullShippingJson` | Parses FullProcessModel JSON, saves complete shipping data |
### Partner & Depot Operations
@@ -0,0 +1,91 @@
# ADR 0004-K9T3: The VAT report's outgoing side reads completed orders, keyed on the pickup date
## Status
Accepted (2026-08-31)
## Context
Measured on PROD, 2026-08-31, for the month 2026-08:
| Source | Population | Gross |
|---|---|---|
| Monthly VAT report, outgoing side | orders that have a cost-consumption row | 126 317 060 Ft |
| nop sales summary, daily | every `Complete` order, by order date | 309 693 697 Ft |
| The company's own books | what was actually invoiced | ~283 500 000 Ft (222 M net, 61.5 M VAT) |
The first figure is not a partial value but a partial period. The outgoing side reads `fbCostConsumption` and takes the month from the consumption row's `Created`. Such rows exist only since the margin feature started producing them on PROD, around 2026-08-20. The nop daily report's running total from 2026-08-20 to 2026-08-31 is 126 317 064 Ft — the reported figure. The report presented twelve days under a thirty-one-day heading.
`DataAvailableFrom = 2026-08-01` was meant to encode that floor. It is a hand-written constant, and it points three weeks earlier than the data starts.
The two halves of the report also use different calendars: the incoming side groups by `PerformanceDate`, the outgoing side by a cost-attribution timestamp. Neither that timestamp nor the nop order date is the date the VAT is owed on.
Four facts shape the options:
- The performance date of a sale is the pickup time, held in `OrderDto.DateOfReceipt`: a `GenericAttribute` on `Order`, written as a string in `MM/dd/yyyy HH:mm:ss` invariant format, store-scoped, `readonly, not-mapped` on the DTO. `DateOfReceiptOrCreated` already encodes a fallback to `CreatedOnUtc`.
- Whether an order reached InnVoice is recorded on the order as `InnVoiceOrderTechId`, `InnVoiceOrderTableId` and `InnVoiceOrderPrintLink` — also GenericAttributes, also strings, also store-scoped, keyed by literals rather than constants. They record that an **order** was submitted. The outbound direction submits an order rather than an invoice (`MGFBANKPLUG-INNV-T-B2N7`), so they do not prove an invoice exists.
- 2026-08 holds ~1223 orders. A month is small enough to filter in memory.
- The gap between the nop figure and the company's books is orders completed but never invoiced. That is a business fact, not a defect of the report.
Scope IN: the outgoing side's population, its month key, and the anomalies it must surface. Scope OUT: the VAT rate per product (its own decision), the incoming side, the margin report's month key (ADR 0002-R7Q2, point 10), and the switch to direct draft-invoice submission.
## Decision
> **An ADR records reasoning, not the state, and binds nothing — `BASE:ARTIFACT_ROLES`.**
**The outgoing side reads every completed order and groups it by the pickup date; what is missing is shown, never substituted.**
1. **Population: every `Complete`, non-deleted order.** An order that never reached InnVoice stays in the figure and is counted as an anomaly. A missing submission is a business exception, not a report filter.
2. **The month is `DateOfReceipt`.** An order without one is placed in no month and appears on an anomaly line. `DateOfReceiptOrCreated` is deliberately not used here: its fallback would hide a missing date behind a plausible month, and the incoming side already treats a missing performance date the same way.
3. **The pickup date stays a `GenericAttribute`.** No column is added to a nop-owned table, so a nopCommerce upgrade migration cannot collide with it.
4. **The period is therefore evaluated in memory.** SQL narrows to the order status; the stored string is parsed with invariant culture. The parse must be invariant: under a Hungarian locale `08/05/2026` reads as 8 May, and that error is silent for every day from 1 to 12.
5. **`DataAvailableFrom` is dropped.** Its reason was the consumption rows' start date; the order list has no such floor.
6. **The two reports keep different calendars.** The VAT report groups by pickup, the margin report by completion. Each is correct for its own purpose, and the difference is stated wherever either is read.
7. **Outgoing VAT stays derived from the gross** with `FruitBankPluginConst.OutgoingVatRate` (pre-flight fix, 2026-08-31), until the per-product rate is decided.
Anomaly lines, each carrying a count: a completed order with no pickup date; a completed order never submitted to InnVoice; a pickup date that does not parse.
## Consequences
**Positive:**
- The month is the whole month, so the report can be held against the accounting figure at all.
- The grouping date is the one VAT is owed on, and both halves of the report answer for the same period.
- Earlier months become answerable, because the order list reaches back as far as the orders do.
- No schema change, so nothing here interacts with a nopCommerce upgrade.
- What is missing is visible: unsubmitted and undated orders are counted rather than dropped.
**Negative:**
- The period filter is not enforced by the database. ~1200 orders a month is small, but nothing in the schema keeps it small.
- The string date stays a trap: a writer using another format, or a reader using another culture, corrupts the month silently for days 1 to 12.
- The report exceeds the invoiced figure by whatever was completed and never invoiced. This is intended, and the anomaly count is what explains the difference.
- A completed order with no pickup date falls out of every month, exactly as an undated supplier invoice does. Its counter is what stops the loss from being silent.
- Two reports over the same sales now group by different dates, which needs explaining to anyone reading both.
**Follow-ups required:**
- `MGFBANKPLUG-INNV-T-R6D2` ([INNVOICE_TODO.md](../INNVOICE/INNVOICE_TODO.md)) carries the implementation status.
- [`INNVOICE_INCOMING.md`](../INNVOICE/INNVOICE_INCOMING.md) → *Monthly VAT report* states the outgoing rules as current fact as they land.
- The InnVoice attribute keys are literals at their call sites; a report reading them would make a second copy. They belong in `FruitBankPluginConst`, beside the other order attribute keys.
## Alternatives considered
- **Keep `fbCostConsumption` as the source** (rejected): its rows begin when the margin feature began, and its date is a cost-attribution timestamp rather than a performance date. The Context measurement is what that costs — twelve days reported as a month.
- **Group by nop's `CreatedOnUtc`** (rejected): it is mapped, indexable and the cheapest of the options, but it is the order date rather than the performance date, and it would leave the report's two halves on different calendars for no gain.
- *Cost:* materially lower than the chosen option — no attribute join, no in-memory filter.
- **Promote `DateOfReceipt` to a first-class column** (rejected, Adam 2026-08-31): it would be SQL-filterable and indexable and would end the string-format trap, but it adds a column to a nop-owned table, where a future nopCommerce upgrade migration could collide with it. The same reasoning does not extend to the plugin's own tables.
- *Reversibility:* low — a migration plus a backfill, and the shared contract would need a co-deploy.
- **Restrict the population to InnVoice-submitted orders** (rejected): submission is not invoicing, and a missing submission is precisely what the report should reveal.
- **Ask InnVoice per order whether an invoice was issued** (rejected): the id-less budget is 20 requests an hour and overrun degrades silently, so a month of orders would exhaust it and then answer with less data without saying so.
## Related
- Related ADRs: [0002-R7Q2](0002-R7Q2-document-cost-layers-and-margin.md) — placed the VAT analytics out of scope, and fixed the margin report's month key that point 6 diverges from.
- Related TODOs/Issues: `MGFBANKPLUG-INNV-T-R6D2` (tracking) · `MGFBANKPLUG-INNV-T-B2N7` (draft-invoice submission, which would make the invoiced / not-invoiced distinction exact).
@@ -23,6 +23,7 @@ Az implementáció állapotát ADR-enként **pontosan egy** `_TODO` / `_ISSUES`
| [0002-R7Q2](0002-R7Q2-document-cost-layers-and-margin.md) | A nyereség-kimutatás a szállítólevél szintjén számol, FIFO-val fogyasztott költség-rétegekből, és minden pénzügyi értéket lekérdezéssel állít elő | Proposed (2026-08-13) | `MGFBANKPLUG-MARGIN-T-K5V9` ([MARGIN_TODO](../MARGIN/MARGIN_TODO.md)) — InProgress | |
| [0003-H7C4](0003-H7C4-dto-event-bridge-wiring.md) | A DTO-repók esemény-hídja bekötésre kerül a keretben, a `Nop.Services` referencia árán | Accepted (2026-08-18) | `MGFBANKPLUG-NOTIF-I-Q3M8` ([NOTIFICATION_ISSUES](../NOTIFICATION/NOTIFICATION_ISSUES.md)) — Closed | |
| [0004-K3D8](0004-K3D8-user-settings-storage.md) | Per-user settings live in a `Customer` `GenericAttribute` as JSON, behind a server-side cache | Proposed (2026-08-27) | `MGFBANKPLUG-USET-T-W6R2` ([USERSETTINGS_TODO](../USERSETTINGS/USERSETTINGS_TODO.md)) — Open | |
| [0004-K9T3](0004-K9T3-vat-report-outgoing-from-orders.md) | The VAT report's outgoing side reads completed orders, keyed on the pickup date | Accepted (2026-08-31) | `MGFBANKPLUG-INNV-T-R6D2` ([INNVOICE_TODO](../INNVOICE/INNVOICE_TODO.md)) — InProgress |
## Ezt a repót érintő döntések más rétegben