7 Commits
Author SHA1 Message Date
Adam d37763ab8a ai document viewimports fix 2026-08-12 09:55:30 +02:00
Adam e70484d8c3 Merge branch 'FruitBank_v0.0.8.0' of https://git.aycode.com/Adam/Mango.Nop.Plugins into FruitBank_v0.0.8.0 2026-08-11 22:41:36 +02:00
AdamandClaude Opus 5 dc0e117483 Termék-törzsadat szerkesztő, kamu e-mail és őrzött adattisztító törlés
ADR 0001-V4T7 (Proposed): a termék öt mérési/logisztikai törzsadata elsőrendű,
plugin-tulajdonú táblába kerüljön a GenericAttribute-ok helyett. A repó első
docs/adr/ mappája, indexszel.

Termék-törzsadat szerkesztő: tömeges, 100-anként lapozó rács sor-szintű mentéssel,
név/cikkszám kereséssel és "csak hiányos" szűrővel. Minden írás a
ProductMasterDataService-en megy át — ez az a határ, ami mögött a tárolás később
cserélhető. A szolgáltatás a hiányzó sort megkülönbözteti a 0 értéktől, és a
validáció hibát ad vissza, nem naplóba nyeli.

Kamu e-mail gomb az admin vásárló-létrehozásához: a nop e-mail nélkül nem enged
tovább, ezért generálunk egyet. Az azonosító a gombnál csak becslés (a vendég-
látogatók is Customer sort kapnak), ezért a beszúrás után az EventConsumer a
tényleges azonosítóra igazítja a címet.

Őrzött, fizikai törlés a Hybrid gridjéhez (Shipping / ShippingDocument /
ShippingItemPallet). A törlés-kaszkád ma is fizikai, ezért a feltétel az egyetlen
védelem — a ShippingCleanupService a tranzakción belül újra kiértékeli.
A dokumentum törlése a fájl-kötéseket mindig viszi, a Files sort és a blobot csak
akkor, ha nem maradt rá másik kötés (hash-alapú deduplikáció miatt osztozhatnak).

Fájl-réteg: a FileSubPath eddig soha nem töltődött ki, így a blob útvonala nem volt
visszanyerhető. Mentéskor most eltároljuk a végleges utat; régi soroknál névre
keresés talál rá és vissza is írja, tehát a hiány migráció nélkül elkopik.

Mellette: az érvénytelen raklap-mérés többé nem nullázza a termék átlagsúlyát.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:01:24 +02:00
AdamandClaude Opus 5 0d2cde4013 A dokumentum-súlyok már a kiolvasáson álljanak elő, és a nettó is számoljon
Az admin eddig csak MENTÉS UTÁN látta a kiszámolt súlyt. Mostantól a rekesz, a
raklap vagy a súlymezők átírásakor azonnal frissül a felületen — ugyanazzal a
képlettel, amit a szerver futtat mentéskor:

  bruttó = raklap × 10 kg + rekesz × (tára + átlagsúly)
  nettó  = rekesz × átlagsúly            (a repo NET_WEIGHT szabályával egyezően)

Eddig csak a bruttó számolódott; a nettó most bekerült.

VISELKEDÉS-VÁLTOZÁS: a kalkuláció mostantól KIZÁRÓLAG nem mérendő terméknél fut.
A feltételből eddig hiányzott az IsMeasurable vizsgálata, így mérendő tételre is
becsültünk. Mérendőnél a dokumentum súlya szerződéses adat: a rendszer később EZT
veti össze a mért értékkel, tehát kitalálni nem szabad — az elfedné az eltérést.

Mérendő tételnél ezért:
- a 0 súlyú mező pirosan keretezve, "⚠ kötelező" jelöléssel és magyarázó tooltippel
- a MENTÉS BLOKKOLVA, amíg a nettó ÉS a bruttó ki nincs töltve; az üzenet felsorolja
  a hiányos tételeket, és megmondja az okot is

Nem mérendőnél a számított értéket halvány "(számított)" jelzés kíséri.

A kliens a számításhoz megkapja a termékadatokat: a kimenő tétel-payload (mindkét
helyen) és a termék-autocomplete válasza is viszi az isMeasurable / tare /
averageWeight mezőt. A tétel-betöltés GetAll(true)-ra állt, különben a ProductDto
nem töltődik és nem lenne miből számolni.

Egyik oldal sem írja felül a dokumentumról kiolvasott, nem nulla értéket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:40:04 +02:00
AdamandClaude Opus 5 5a7de3a414 A vevői értesítések ne bukjanak némán, és ne írjunk e-mail-címet konzolra
Élesben kiderült, hogy a "Rendelésed feldolgozás alatt" sablon TÖRZSE üres volt
(<p></p>), az "elkészült" sablon TÁRGYA pedig a másikról másolódott át. Egyik sem
tűnt fel, mert a szolgáltatás hiányzó vagy inaktív sablonnál némán kilépett
(return 0, nyom nélkül).

- Nop.Services.Logging.ILogger bekötve (teljesen minősítve, hogy ne ütközzön a
  Microsoft.Extensions.Logging.ILogger-rel). A bejegyzések az Admin → Rendszer →
  Napló alatt kereshetők.
- Hiányzó és inaktív sablon külön ágon, beszédes figyelmeztetéssel: sablonnév,
  store és rendelésszám. A hiányzó sablon üzenete kimondja az okot is — a
  sablonokat az InstallAsync hozza létre, ami már telepített pluginnál nem fut le
  újra —, hogy aki a naplóban találkozik vele, ne kelljen újra végignyomoznia.
- Console.WriteLine törölve: a vevő e-mail-címét írta a szerver konzoljára.
- Null-ágak lezárva: ha sem a sablon e-mail-fiókja, sem az alapértelmezett nincs
  meg, illetve ha a rendelés vevője nem található (törölt vevő) — eddig az utóbbi
  a customer.Email-nél szállt el.

A sablonok tartalmát külön, tranzakcióba csomagolt SQL-lel javítottuk éles DB-n;
a szövegek forrása a FruitBankPlugin.InstallAsync kanonikus alapértéke, hogy ne
legyen drift a kód és az éles adat között.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:00:25 +02:00
AdamandClaude Opus 5 ced5e6a738 Tömeges beküldés Innvoice-ba a megrendelés-listáról
A listán eddig a tömeges kijelölés csak exportra és PDF-letöltésre volt jó. Új
gomb küldi be a kijelölt megrendeléseket — kijelölés nélkül inaktív.

- Csak a MÉG NEM beküldötteket küldi: a beküldöttnek van InnvoiceTechId-ja (ez
  hajtja a lista Innvoice-oszlopát is). A már beküldötteket kihagyja, és ezt az
  összegzésben jelzi.
- Rendelésenként ugyanazt a végpontot hívja (InnVoiceOrder/CreateOrder), amit a
  megrendelés-szerkesztő "Megrendelés beküldése" gombja.
- SORBAN, egyesével: az Innvoice külső rendszer, nem terheljük párhuzamos
  hívásokkal, és így a részeredmény is értelmezhető marad. A gombon látszik a
  haladás.
- Egy hibás rendelés nem állítja meg a sort; a végén összegzés jön a
  sikertelenekről rendelésszámmal és hibaüzenettel. (A végpont a hibát is HTTP
  200-zal jelzi a törzsben, ezért a success flaget nézzük.)
- A grid a végén újratölt, hogy az Innvoice-oszlop frissüljön.

A gomb állapotát a sor-checkbox, a "mindet kijelöl" és a grid újrarajzolása is
frissíti — lapozáskor a kijelölés elvész.

Ismert korlát: a ciklus a böngészőben fut, tehát elnavigálás megszakítja. Sok
tételnél szerver-oldali tömeges végpont lehet a jobb, haladás-jelzés nélkül.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:20:32 +02:00
AdamandClaude Opus 5 1677087498 Az admin-menü leszűkítése profil szerint, a többi a "Haladó" alá
A nopCommerce teljes admin-menüje zajos a napi nagykeres munkához. A főmenüben
csak az marad, ami az adminnak kell: a saját menüpontjaink, a Dashboard, a
Katalógus > Termékek/Kategóriák, az Eladások > Rendelések és a Vásárlók >
Vásárlók listája. Minden más egy "Haladó" csoport alá kerül, az eredeti
csoport-szerkezetét megtartva — semmi nem vész el, csak egy kattintással
messzebb kerül.

FEHÉRLISTA, nem feketelista: ami nincs a profilban, az a Haladó alá megy. Így egy
nop-frissítés új menüpontja sem szivárog vissza a főmenübe.

A csomópontokat MOZGATJUK, nem másoljuk: a jogosultság-szűrés, a lokalizált
címek, az URL-ek és az ikonok változatlanul működnek. A PermissionNames a nopban
a gyerekekből származik, tehát a mozgatással magától helyes marad.

Szerep-függőségre előkészítve (a bővítés ne igényeljen újraírást):
- AdminMenuProfile = ADAT (mely SystemName-ek maradnak kint)
- AdminMenuShaper = LOGIKA (a fa átrendezése), profil-független
- IAdminMenuProfileProvider = a választás; ma egy profil mindenkinek, később
  szerep szerint. null visszaadása = teljes nop-menü (a jövőbeli fejlesztői szerep)

Az "AI" konfigurációs almenü beszúrása külön metódusba került: három korai
return-je volt, ami csendben kihagyta volna a menü átrendezését — és bármit,
amit később valaki a handler végére tesz.

A "Haladó" cím lokalizált; mivel az erőforrás az InstallAsync-ben jön létre és
már telepített pluginnál nem fut újra, a shaper tartalék címmel indul.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:09:16 +02:00
27 changed files with 2062 additions and 19 deletions
@@ -1165,6 +1165,11 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
price = product.Price,
stockQuantity = product.StockQuantity,
incomingQuantity = productDto.IncomingQuantity,
// A szállítólevél-kiolvasás felülete ebből számol nettó/bruttó súlyt NEM MÉRENDŐ terméknél,
// már mentés előtt. Mérendőnél nincs kalkuláció: ott a dokumentum súlya szerződéses adat.
isMeasurable = productDto.IsMeasurable,
tare = productDto.Tare,
averageWeight = productDto.AverageWeight,
});
}
}
@@ -1,6 +1,8 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Nop.Core.Domain.Customers;
using Nop.Data;
using Nop.Plugin.Misc.FruitBankPlugin.Helpers;
using Nop.Plugin.Misc.FruitBankPlugin.Models;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Customers;
@@ -24,15 +26,18 @@ public class CustomerAttributesController : BasePluginController
private readonly FruitBankAttributeService _attributeService;
private readonly ICustomerService _customerService;
private readonly IPermissionService _permissionService;
private readonly IRepository<Customer> _customerRepository;
public CustomerAttributesController(
FruitBankAttributeService attributeService,
ICustomerService customerService,
IPermissionService permissionService)
IPermissionService permissionService,
IRepository<Customer> customerRepository)
{
_attributeService = attributeService;
_customerService = customerService;
_permissionService = permissionService;
_customerRepository = customerRepository;
}
[HttpPost]
@@ -117,4 +122,31 @@ public class CustomerAttributesController : BasePluginController
return Json(new { success = true, plateCount = entries.Count, defaultPlate = effectiveDefault });
}
/// <summary>
/// Helyettesítő („kamu") e-mail cím javaslata az admin vásárló-létrehozó űrlapjára, azoknak a
/// vevőknek, akik nem adnak meg címet (e-mail nélkül a nop nem enged tovább).
/// <para>
/// ⚠️ Az azonosító itt csak <b>becslés</b>: a legnagyobb létező <c>Customer.Id</c> + 1. A nopCommerce
/// a bolt vendég-látogatóinak is <c>Customer</c> sort hoz létre, tehát a gomb megnyomása és a mentés
/// között az azonosító <b>elcsúszhat</b>. Ezt nem itt kezeljük: a beszúrás után az
/// <c>EventConsumer</c> a <b>tényleges</b> azonosítóra igazítja a címet.
/// </para>
/// </summary>
[HttpPost]
[Route("Admin/CustomerAttributes/SuggestPlaceholderEmail")]
public async Task<IActionResult> SuggestPlaceholderEmail()
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, error = "Access denied" });
// Teljes névvel: a parméter nélküli FirstOrDefaultAsync kétértelmű a nop
// System.Linq.AsyncIQueryableExtensions és a LinqToDB.AsyncExtensions között.
var lastCustomerId = await LinqToDB.AsyncExtensions.FirstOrDefaultAsync(
_customerRepository.Table
.OrderByDescending(c => c.Id)
.Select(c => c.Id));
return Json(new { success = true, email = PlaceholderEmailHelper.Build(lastCustomerId + 1) });
}
}
@@ -148,7 +148,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
// ✅ Manually load related data
var shippingItems = await _dbContext.ShippingItems
.GetAll()
.GetAll(true)
.Where(si => si.ShippingDocumentId == existingShippingDoc.Id)
.ToListAsync();
@@ -179,6 +179,9 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
hungarianName = item.HungarianName,
nameOnDocument = item.NameOnDocument,
productId = item.ProductId,
isMeasurable = item.ProductDto?.IsMeasurable ?? false,
tare = item.ProductDto?.Tare ?? 0d,
averageWeight = item.ProductDto?.AverageWeight ?? 0d,
palletsOnDocument = item.PalletsOnDocument,
quantityOnDocument = item.QuantityOnDocument,
netWeightOnDocument = item.NetWeightOnDocument,
@@ -382,6 +385,9 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
hungarianName = item.HungarianName,
nameOnDocument = item.NameOnDocument,
productId = item.ProductId,
isMeasurable = item.ProductDto?.IsMeasurable ?? false,
tare = item.ProductDto?.Tare ?? 0d,
averageWeight = item.ProductDto?.AverageWeight ?? 0d,
palletsOnDocument = item.PalletsOnDocument,
quantityOnDocument = item.QuantityOnDocument,
netWeightOnDocument = item.NetWeightOnDocument,
@@ -1039,13 +1045,29 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
itemDto.IsMeasurable = productDto.IsMeasurable;
}
// If the gross weight wasn't extracted/entered, compute it now that the pallet count is
// final (user-corrected): pallets × pallet weight + crates × (tare + average unit weight).
// The inbound EKÁER declaration reads GrossWeightOnDocument.
// Hiányzó súlyok kiszámítása — KIZÁRÓLAG NEM MÉRENDŐ terméknél.
//
// Mérendő tételnél a dokumentum súlya SZERZŐDÉSES adat: a rendszer később ezt veti össze a mért
// értékkel, tehát nem szabad kitalálni. Ha a kiolvasás hiányos, az adminnak kézzel kell pótolnia
// (a kiolvasás-ellenőrzőn a 0 érték pirosan jelenik meg). Korábban ez a feltétel hiányzott, és
// mérendő tételre is becsültünk — az elfedte volna az eltérést a mért adattal szemben.
//
// bruttó = raklap × raklapsúly + rekesz × (tára + átlagsúly)
// nettó = rekesz × átlagsúly (a repo NET_WEIGHT szabályával konzisztensen)
//
// A bejövő EKÁER-bejelentés a GrossWeightOnDocument-et olvassa.
var grossWeightOnDocument = itemDto.GrossWeightOnDocument;
if (grossWeightOnDocument <= 0 && productDto != null)
grossWeightOnDocument = itemDto.PalletsOnDocument * FruitBankConstClient.PalletWeightKg
+ itemDto.QuantityOnDocument * (productDto.Tare + productDto.AverageWeight);
var netWeightOnDocument = itemDto.NetWeightOnDocument;
if (productDto is { IsMeasurable: false })
{
if (grossWeightOnDocument <= 0)
grossWeightOnDocument = itemDto.PalletsOnDocument * FruitBankConstClient.PalletWeightKg
+ itemDto.QuantityOnDocument * (productDto.Tare + productDto.AverageWeight);
if (netWeightOnDocument <= 0)
netWeightOnDocument = itemDto.QuantityOnDocument * productDto.AverageWeight;
}
var shippingItem = new ShippingItem
{
@@ -1055,7 +1077,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
ProductId = itemDto.ProductId,
PalletsOnDocument = itemDto.PalletsOnDocument,
QuantityOnDocument = itemDto.QuantityOnDocument,
NetWeightOnDocument = itemDto.NetWeightOnDocument,
NetWeightOnDocument = netWeightOnDocument,
GrossWeightOnDocument = grossWeightOnDocument,
UnitPriceOnDocument = itemDto.UnitPriceOnDocument,
IsMeasurable = itemDto.IsMeasurable
@@ -0,0 +1,128 @@
#nullable enable
using Microsoft.AspNetCore.Mvc;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Security;
using Nop.Web.Areas.Admin.Controllers;
using Nop.Web.Framework.Mvc.Filters;
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
/// <summary>
/// Tömeges termék-törzsadat szerkesztő: mérendő / tára / átlagsúly / küszöb / rekesz-raklap.
/// <para>
/// Minden írás a <see cref="ProductMasterDataService"/>-en megy át — az a határ, ami mögött az
/// ADR 0001-V4T7 szerint később a tárolás <c>GenericAttribute</c>-ról tipizált oszlopokra cserélhető
/// anélkül, hogy ez a felület újraíródna.
/// </para>
/// </summary>
[AuthorizeAdmin]
[Area(Nop.Web.Framework.AreaNames.ADMIN)]
public class ProductMasterDataController(ProductMasterDataService masterDataService) : BaseAdminController
{
private const string VIEW_PATH = "~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/ProductMasterData/Index.cshtml";
[CheckPermission(StandardPermission.Catalog.PRODUCTS_VIEW)]
public IActionResult Index() => View(VIEW_PATH);
/// <summary>DataTables szerver-oldali végpont a rácshoz.</summary>
[HttpPost]
[CheckPermission(StandardPermission.Catalog.PRODUCTS_VIEW)]
public async Task<IActionResult> List()
{
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out var draw); draw = Math.Max(draw, 1);
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out var start); start = Math.Max(start, 0);
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out var length);
length = length < 1 ? 100 : Math.Min(length, 500);
_ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out var sortColIdx);
var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "asc";
var sortField = Request.Form[$"columns[{sortColIdx}][data]"].FirstOrDefault();
var search = Request.Form["fbSearch"].FirstOrDefault();
var onlyIncomplete = Request.Form["fbOnlyIncomplete"].FirstOrDefault() == "true";
var (rows, totalCount, filteredCount) = await masterDataService.SearchAsync(
search, onlyIncomplete, start, length, sortField,
string.Equals(sortDir, "desc", StringComparison.OrdinalIgnoreCase));
return Json(new
{
draw,
recordsTotal = totalCount,
recordsFiltered = filteredCount,
data = rows.Select(r => new
{
r.Id,
r.Name,
r.Sku,
IsMeasurable = r.Values.IsMeasurable,
Tare = r.Values.Tare,
AverageWeight = r.Values.AverageWeight,
AverageWeightTreshold = r.Values.AverageWeightTreshold,
CratesPerPallet = r.Values.CratesPerPallet,
IsIncomplete = r.Values.IsIncomplete
})
});
}
/// <summary>
/// Sor-szintű mentés. Az <b>üres</b> mező törli az attribútumot („nincs beállítva").
/// <para>
/// ⚠️ A számok <b>szövegként</b> érkeznek, és a szolgáltatás értelmezi őket InvariantCulture-rel.
/// Ha <c>double?</c>/<c>int?</c> paraméterekre bíznánk, az MVC model-binding a kérés aktuális
/// kultúráját használná (magyar boltnál tizedesvessző), a „12.5" némán <c>null</c> lenne — és a
/// <c>null</c> itt törlést jelent, tehát a mentés kinullázná a meglévő adatot.
/// </para>
/// </summary>
[HttpPost]
[CheckPermission(StandardPermission.Catalog.PRODUCTS_CREATE_EDIT_DELETE)]
public async Task<IActionResult> SaveRow(int productId, bool isMeasurable, string? tare,
string? averageWeight, string? averageWeightTreshold, string? cratesPerPallet)
{
var parseErrors = new List<string>();
if (!ProductMasterDataService.TryParseOptionalDouble(tare, out var tareValue))
parseErrors.Add("A tára nem értelmezhető számként.");
if (!ProductMasterDataService.TryParseOptionalDouble(averageWeight, out var averageWeightValue))
parseErrors.Add("Az átlagsúly nem értelmezhető számként.");
if (!ProductMasterDataService.TryParseOptionalDouble(averageWeightTreshold, out var tresholdValue))
parseErrors.Add("A küszöb nem értelmezhető számként.");
if (!ProductMasterDataService.TryParseOptionalInt(cratesPerPallet, out var cratesValue))
parseErrors.Add("A rekesz/raklap nem értelmezhető egész számként.");
// Értelmezhetetlen bemenetre semmit nem írunk — se részlegesen, se törlésként.
if (parseErrors.Count > 0)
return Json(new { success = false, message = string.Join(" ", parseErrors) });
var values = new ProductMasterDataValues
{
IsMeasurable = isMeasurable,
Tare = tareValue,
AverageWeight = averageWeightValue,
AverageWeightTreshold = tresholdValue,
CratesPerPallet = cratesValue
};
var errors = await masterDataService.SaveAsync(productId, values);
if (errors.Count > 0)
return Json(new { success = false, message = string.Join(" ", errors) });
var saved = await masterDataService.GetAsync(productId);
return Json(new
{
success = true,
row = new
{
Id = productId,
IsMeasurable = saved.IsMeasurable,
Tare = saved.Tare,
AverageWeight = saved.AverageWeight,
AverageWeightTreshold = saved.AverageWeightTreshold,
CratesPerPallet = saved.CratesPerPallet,
IsIncomplete = saved.IsIncomplete
}
});
}
}
@@ -692,12 +692,12 @@
<input type="number" class="form-control form-control-sm" value="${item.quantityOnDocument || 0}" data-field="quantityOnDocument" data-index="${index}">
</div>
<div>
<label>Nettó súly (kg)</label>
<input type="number" step="0.01" class="form-control form-control-sm" value="${item.netWeightOnDocument ? item.netWeightOnDocument.toFixed(2) : '0.00'}" data-field="netWeightOnDocument" data-index="${index}">
<label>Nettó súly (kg) ${weightHint(item, item.netWeightOnDocument)}</label>
<input type="number" step="0.01" class="form-control form-control-sm ${weightClass(item, item.netWeightOnDocument)}" value="${item.netWeightOnDocument ? item.netWeightOnDocument.toFixed(2) : '0.00'}" data-field="netWeightOnDocument" data-index="${index}">
</div>
<div>
<label>Bruttó súly (kg)</label>
<input type="number" step="0.01" class="form-control form-control-sm" value="${item.grossWeightOnDocument ? item.grossWeightOnDocument.toFixed(2) : '0.00'}" data-field="grossWeightOnDocument" data-index="${index}">
<label>Bruttó súly (kg) ${weightHint(item, item.grossWeightOnDocument)}</label>
<input type="number" step="0.01" class="form-control form-control-sm ${weightClass(item, item.grossWeightOnDocument)}" value="${item.grossWeightOnDocument ? item.grossWeightOnDocument.toFixed(2) : '0.00'}" data-field="grossWeightOnDocument" data-index="${index}">
</div>
<div>
<label>Egységár</label>
@@ -721,6 +721,10 @@
shippingItems[idx][field] = value;
if (field === 'palletsOnDocument') updateTotalPallets();
// A súlyok azonnal, MENTÉS ELŐTT álljanak elő — az admin eddig csak mentés után látta őket.
if (['palletsOnDocument', 'quantityOnDocument', 'netWeightOnDocument', 'grossWeightOnDocument'].includes(field))
recalcWeights(idx);
});
});
@@ -728,6 +732,47 @@
return card;
}
/* ── Súly-kalkuláció (nem mérendő termékre) ──────────────────────
A szerver ugyanezt a képletet futtatja mentéskor; ez csak azért van itt, hogy az admin MÉG MENTÉS ELŐTT
lássa az értéket. MÉRENDŐ terméknél szándékosan NEM számol: ott a dokumentum súlya szerződéses adat,
amit a rendszer később a mért értékkel vet össze — kitalálni elfedné az eltérést. Ott csak jelzünk. */
/* Teljesen minősítve: a plugin nézeteinek nincs `@@using FruitBank.Common`-ja, és a .cshtml FUTÁSIDŐBEN
fordul — egy hiányzó névtér itt nem build-hibaként, hanem az oldal megnyitásakor csap le. */
var PALLET_WEIGHT_KG = @FruitBank.Common.FruitBankConstClient.PalletWeightKg;
/* MÉRENDŐ terméknél a 0 súly HIÁNYZÓ ADAT, nem nulla: a dokumentumon kötelezően szerepel, az adminnak
kézzel kell kikeresnie. Ezért pirosan jelöljük — nem tudjuk és nem is szabad kiszámolni helyette. */
function weightClass(item, value) {
return (item.isMeasurable && !(value > 0)) ? 'is-invalid border-danger' : '';
}
function weightHint(item, value) {
if (item.isMeasurable)
return !(value > 0)
? '<span class="text-danger" title="Mérendő termék: a dokumentumon szereplő súlyt kötelező beírni — a rendszer ezt veti össze a mért értékkel.">⚠ kötelező</span>'
: '';
return (value > 0) ? '<small class="text-muted">(számított)</small>' : '';
}
function recalcWeights(idx) {
var item = shippingItems[idx];
if (!item || item.isMeasurable) { renderShippingItems(); return; }
var tare = item.tare || 0;
var avg = item.averageWeight || 0;
var crates = item.quantityOnDocument || 0;
if (tare <= 0 && avg <= 0) { renderShippingItems(); return; } // nincs termék-törzsadat, nincs miből
if (!(item.grossWeightOnDocument > 0))
item.grossWeightOnDocument = Math.round(((item.palletsOnDocument || 0) * PALLET_WEIGHT_KG + crates * (tare + avg)) * 100) / 100;
if (!(item.netWeightOnDocument > 0))
item.netWeightOnDocument = Math.round((crates * avg) * 100) / 100;
renderShippingItems();
}
function addNewShippingItem() {
shippingItems.push({
name: '', hungarianName: '', nameOnDocument: '', productId: null,
@@ -885,6 +930,24 @@
if (!currentPartner.id) { showMessage('Kérlek válassz partnert a mentés előtt.', 'warning'); return; }
/* MÉRENDŐ tételnél a dokumentum-súly kötelező: a rendszer később EZT veti össze a mért értékkel.
Nem mérendőnél nincs ellenőrzés — ott a hiányzó súlyt kiszámoljuk. */
const missingWeights = shippingItems
.map((item, i) => ({ item: item, no: i + 1 }))
.filter(x => x.item.isMeasurable && (!(x.item.netWeightOnDocument > 0) || !(x.item.grossWeightOnDocument > 0)));
if (missingWeights.length > 0) {
const list = missingWeights
.map(x => '#' + x.no + ' ' + (x.item.hungarianName || x.item.nameOnDocument || 'névtelen tétel'))
.join(', ');
showMessage('Mérendő tételnél kötelező a dokumentumon szereplő nettó ÉS bruttó súly — ezt nem számoljuk ki, '
+ 'mert a mérésnél ehhez hasonlítunk. Hiányzik: ' + list, 'warning');
renderShippingItems(); // a piros jelölés frissüljön
return;
}
const doc = {
documentIdNumber: document.getElementById('editDocumentIdNumber').value,
partnerId: currentPartner.id,
@@ -40,6 +40,12 @@
<li class="dropdown-item"><button type="button" id="pdf-invoice-selected">@T("Admin.Orders.PdfInvoices.Selected")</button></li>
</ul>
</div>
@* Tömeges beküldés: ugyanazt a végpontot hívja rendelésenként, mint a megrendelés-szerkesztő
„Megrendelés beküldése" gombja. Kijelölés nélkül inaktív. *@
<button type="button" id="innvoice-submit-selected" class="btn btn-warning" disabled
title="A kijelölt, még be nem küldött megrendelések beküldése Innvoice-ba">
<i class="fas fa-paper-plane"></i> Beküldés Innvoice-ba
</button>
</div>
</div>
</form>
@@ -562,6 +568,90 @@ $(function () {
$('#exportexcel-selected').on('click', function (e) { e.preventDefault(); exportSelected('#export-excel-selected-form', '#export-excel-ids'); });
$('#pdf-invoice-selected').on('click', function (e) { e.preventDefault(); exportSelected('#pdf-invoice-selected-form', '#pdf-invoice-ids'); });
/* ── Tömeges beküldés Innvoice-ba ────────────────────────────── */
/* A kijelölt SOROK adatai (nem csak az id-k): az InnvoiceTechId-ből tudjuk, mi van már beküldve. */
function getSelectedRows() {
var rows = [];
$('#fb-orders-grid tbody .fb-row-check:checked').each(function () {
var data = table.row($(this).closest('tr')).data();
if (data) rows.push(data);
});
return rows;
}
function refreshBulkButtons() {
$('#innvoice-submit-selected').prop('disabled', getSelectedIds().length === 0);
}
/* Delegált, mert a DataTables újrarajzoláskor kicseréli a sorokat (és a kijelölés is elvész). */
$('#fb-orders-grid').on('change', '.fb-row-check', refreshBulkButtons);
$('#fb-check-all').on('change', refreshBulkButtons);
table.on('draw', refreshBulkButtons);
$('#innvoice-submit-selected').on('click', function () {
var rows = getSelectedRows();
/* Csak a MÉG NEM beküldöttek mennek: a beküldött rendelésnek van InnvoiceTechId-ja. */
var pending = rows.filter(function (row) { return !row.InnvoiceTechId; });
var skipped = rows.length - pending.length;
if (pending.length === 0) {
alert(skipped > 0
? 'A kijelölt ' + skipped + ' megrendelés mindegyike már be van küldve Innvoice-ba.'
: 'Nincs kijelölt megrendelés.');
return;
}
if (!confirm(pending.length + ' megrendelés beküldése Innvoice-ba'
+ (skipped > 0 ? ' (' + skipped + ' már beküldött kihagyva)' : '') + '.\n\nFolytatod?')) return;
var $btn = $(this);
var originalHtml = $btn.html();
var failures = [];
var index = 0;
$btn.prop('disabled', true);
/* SORBAN, egyesével — az Innvoice külső rendszer, nem terheljük párhuzamos hívásokkal, és így a
részeredmény is értelmezhető marad, ha valamelyik elbukik. */
function submitNext() {
if (index >= pending.length) {
$btn.html(originalHtml);
table.ajax.reload(null, false); // a beküldött sorok Innvoice-oszlopa frissüljön
refreshBulkButtons();
var message = (pending.length - failures.length) + ' megrendelés beküldve.';
if (skipped > 0) message += '\n' + skipped + ' már be volt küldve, kihagyva.';
if (failures.length > 0) message += '\n\nSikertelen (' + failures.length + '):\n' + failures.join('\n');
alert(message);
return;
}
var row = pending[index];
var label = '#' + (row.CustomOrderNumber || row.Id);
$btn.html('<i class="fas fa-spinner fa-spin"></i> Beküldés… (' + (index + 1) + '/' + pending.length + ')');
$.ajax({
url: '@Url.Action("CreateOrder", "InnVoiceOrder")',
type: 'POST',
data: { orderId: row.Id }
}).done(function (response) {
/* A végpont sikertelenséget is HTTP 200-zal jelez, a törzsben — ezért a success flaget nézzük. */
if (!response || response.success === false)
failures.push(label + ': ' + ((response && response.message) || 'ismeretlen hiba'));
}).fail(function (xhr) {
failures.push(label + ': HTTP ' + xhr.status);
}).always(function () {
index++;
submitNext();
});
}
submitNext();
});
/* ── Create order modal ──────────────────────────────────────── */
var createProducts = [];
@@ -0,0 +1,278 @@
@using Nop.Web.Framework.Infrastructure
@{
ViewBag.PageTitle = "Termék-törzsadat";
NopHtml.SetActiveMenuItemSystemName("FruitBank");
}
@* ── Header ─────────────────────────────────────────────────────── *@
<div class="content-header clearfix">
<h1 class="float-left">Termék-törzsadat</h1>
</div>
<section class="content">
<div class="container-fluid">
@* ── Filter panel ───────────────────────────────────────────── *@
<div class="card card-default card-search mb-2">
<div class="card-body py-2">
<div class="row align-items-end">
<div class="col-md-5">
<div class="form-group mb-1">
<label class="col-form-label">Név vagy cikkszám</label>
<input type="text" id="fb-pmd-search" class="form-control" autocomplete="off"
placeholder="Termék neve vagy SKU..." />
</div>
</div>
<div class="col-md-4">
<div class="form-group mb-1">
<label class="col-form-label">&nbsp;</label>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="fb-pmd-only-incomplete" />
<label class="form-check-label" for="fb-pmd-only-incomplete">
Csak a hiányos törzsadatúak
</label>
</div>
</div>
</div>
<div class="col-md-3">
<div class="form-group mb-1">
<label class="col-form-label">&nbsp;</label>
<button type="button" id="fb-pmd-search-btn" class="btn btn-primary btn-block">
<i class="fas fa-search"></i> Keresés
</button>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<small class="text-muted">
Hiányos az a sor, ahol nincs <b>tára</b>, vagy nincs / nem pozitív az
<b>átlagsúly</b> és a <b>rekesz/raklap</b> — mérendő terméknél a <b>küszöb</b> is.
Az üresen hagyott mező a mentéskor <b>törli</b> az értéket („nincs beállítva").
</small>
</div>
</div>
</div>
</div>
@* ── Grid ───────────────────────────────────────────────────── *@
<div class="card card-default">
<div class="card-body p-0">
@Html.AntiForgeryToken()
<table id="fb-pmd-grid" class="table table-bordered table-hover m-0" style="width:100%">
<thead>
<tr>
<th style="width:70px">Id</th>
<th>Termék</th>
<th style="width:130px">Cikkszám</th>
<th style="width:90px" title="Mérendő-e a termék">Mérendő</th>
<th style="width:110px" title="Egy rekesz tárasúlya (kg)">Tára</th>
<th style="width:130px" title="Várt átlagsúly rekeszenként (kg)">Átlagsúly</th>
<th style="width:120px" title="Megengedett eltérés %-ban">Küszöb %</th>
<th style="width:120px" title="Hány rekesz fér egy raklapra">Rekesz/raklap</th>
<th style="width:90px"></th>
</tr>
</thead>
</table>
</div>
</div>
</div>
</section>
<style>
#fb-pmd-grid tbody td { vertical-align: middle; }
#fb-pmd-grid tbody tr.fb-incomplete { background-color: #fff6f6; }
#fb-pmd-grid tbody tr.fb-dirty { background-color: #fffbe6; }
#fb-pmd-grid tbody input.fb-cell {
width: 100%;
height: 28px;
padding: 1px 6px;
font-size: 12px;
border: 1px solid #ced4da;
border-radius: 3px;
}
#fb-pmd-grid tbody input.fb-cell:focus {
outline: none;
border-color: #80bdff;
box-shadow: 0 0 0 2px rgba(0,123,255,.15);
}
.fb-missing { border-color: #dc3545 !important; background-color: #fff0f0; }
</style>
<script>
$(function () {
var _token = $('input[name="__RequestVerificationToken"]').val();
function num(v) { return (v === null || v === undefined) ? '' : v; }
/* A NYERS szöveget küldjük fel — a szám értelmezése a szerveré (InvariantCulture, vesszőt is elfogad).
Ha itt parse-olnánk és számot küldenénk, a magyar kultúrájú model-binding a „12.5"-öt némán
null-ra bindolná, a null pedig TÖRLÉST jelent — így a mentés kinullázná a meglévő adatot.
Az üres mező továbbra is szándékos törlés („nincs beállítva"). */
function rawCell($input) {
return $.trim($input.val());
}
function cellHtml(field, value, isMissing) {
return '<input type="text" class="fb-cell' + (isMissing ? ' fb-missing' : '') +
'" data-field="' + field + '" value="' + num(value) + '" />';
}
var table = $('#fb-pmd-grid').DataTable({
serverSide : true,
processing : true,
stateSave : false,
searching : false,
pageLength : 100,
lengthMenu : [[100, 200, 500], [100, 200, 500]],
order : [[1, 'asc']],
language : {
processing : 'Betöltés...',
lengthMenu : '_MENU_ sor/oldal',
info : '_START__END_ / _TOTAL_ termék',
infoEmpty : '0 termék',
infoFiltered : '(szűrve _MAX_-ból)',
paginate : { first: '««', previous: '«', next: '»', last: '»»' },
emptyTable : 'Nincs találat',
zeroRecords : 'Nincs találat a szűrési feltételekre'
},
ajax: {
url : '@Url.Action("List", "ProductMasterData")',
type: 'POST',
data: function (d) {
d.__RequestVerificationToken = _token;
d.fbSearch = $('#fb-pmd-search').val();
d.fbOnlyIncomplete = $('#fb-pmd-only-incomplete').is(':checked') ? 'true' : 'false';
}
},
columns: [
{ data: 'Id', orderable: true },
{ data: 'Name', orderable: true },
{ data: 'Sku', orderable: true },
{
data: 'IsMeasurable', orderable: true, className: 'text-center',
render: function (v) {
return '<input type="checkbox" class="fb-cell-check" data-field="IsMeasurable"' +
(v === true ? ' checked' : '') + ' />';
}
},
{
data: 'Tare', orderable: true,
render: function (v) { return cellHtml('Tare', v, v === null || v === undefined); }
},
{
data: 'AverageWeight', orderable: true,
render: function (v) { return cellHtml('AverageWeight', v, v === null || v === undefined || v <= 0); }
},
{
data: 'AverageWeightTreshold', orderable: true,
render: function (v, t, row) {
/* A küszöb csak mérendő terméknél számít — a validáció rövidre zár nem mérendőnél. */
var missing = row.IsMeasurable === true && (v === null || v === undefined || v <= 0);
return cellHtml('AverageWeightTreshold', v, missing);
}
},
{
data: 'CratesPerPallet', orderable: true,
render: function (v) { return cellHtml('CratesPerPallet', v, v === null || v === undefined || v <= 0); }
},
{
data: null, orderable: false, className: 'text-center',
render: function () {
return '<button type="button" class="btn btn-sm btn-primary fb-save-row" disabled title="Sor mentése">' +
'<i class="fas fa-save"></i></button>';
}
}
],
createdRow: function (rowEl, data) {
if (data.IsIncomplete) $(rowEl).addClass('fb-incomplete');
$(rowEl).attr('data-product-id', data.Id);
}
});
/* ── Piszkos sor jelzése + a mentés gomb engedélyezése ───────── */
$('#fb-pmd-grid tbody').on('input change', '.fb-cell, .fb-cell-check', function () {
var $row = $(this).closest('tr');
$row.addClass('fb-dirty');
$row.find('.fb-save-row').prop('disabled', false);
});
/* Enter a sor mentését indítja */
$('#fb-pmd-grid tbody').on('keydown', '.fb-cell', function (e) {
if (e.which === 13) {
e.preventDefault();
$(this).closest('tr').find('.fb-save-row').trigger('click');
}
});
/* ── Sor-szintű mentés ───────────────────────────────────────── */
$('#fb-pmd-grid tbody').on('click', '.fb-save-row', function () {
var $btn = $(this);
var $row = $btn.closest('tr');
var productId = $row.attr('data-product-id');
function cell(field) { return $row.find('.fb-cell[data-field="' + field + '"]'); }
var payload = {
__RequestVerificationToken: _token,
productId : productId,
isMeasurable : $row.find('.fb-cell-check[data-field="IsMeasurable"]').is(':checked'),
tare : rawCell(cell('Tare')),
averageWeight : rawCell(cell('AverageWeight')),
averageWeightTreshold: rawCell(cell('AverageWeightTreshold')),
cratesPerPallet : rawCell(cell('CratesPerPallet'))
};
$btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>');
$.ajax({
url : '@Url.Action("SaveRow", "ProductMasterData")',
type: 'POST',
data: payload
}).done(function (response) {
if (!response || response.success === false) {
$btn.prop('disabled', false).html('<i class="fas fa-save"></i>');
alert((response && response.message) || 'A mentés nem sikerült.');
return;
}
/* A szerver visszaadja a ténylegesen eltárolt értékeket — azokkal frissítünk,
hogy a rács ne a beírt, hanem a mentett állapotot mutassa. */
var row = response.row;
cell('Tare').val(num(row.Tare));
cell('AverageWeight').val(num(row.AverageWeight));
cell('AverageWeightTreshold').val(num(row.AverageWeightTreshold));
cell('CratesPerPallet').val(num(row.CratesPerPallet));
$row.find('.fb-cell-check[data-field="IsMeasurable"]').prop('checked', row.IsMeasurable === true);
markMissing(cell('Tare'), row.Tare === null);
markMissing(cell('AverageWeight'), row.AverageWeight === null || row.AverageWeight <= 0);
markMissing(cell('AverageWeightTreshold'),
row.IsMeasurable === true && (row.AverageWeightTreshold === null || row.AverageWeightTreshold <= 0));
markMissing(cell('CratesPerPallet'), row.CratesPerPallet === null || row.CratesPerPallet <= 0);
$row.removeClass('fb-dirty').toggleClass('fb-incomplete', row.IsIncomplete === true);
$btn.html('<i class="fas fa-check"></i>');
setTimeout(function () { $btn.html('<i class="fas fa-save"></i>'); }, 1200);
}).fail(function () {
$btn.prop('disabled', false).html('<i class="fas fa-save"></i>');
alert('A mentés nem sikerült (hálózati hiba).');
});
});
function markMissing($input, isMissing) {
$input.toggleClass('fb-missing', !!isMissing);
}
/* ── Szűrés ──────────────────────────────────────────────────── */
$('#fb-pmd-search-btn').on('click', function () { table.ajax.reload(); });
$('#fb-pmd-only-incomplete').on('change', function () { table.ajax.reload(); });
$('#fb-pmd-search').on('keydown', function (e) {
if (e.which === 13) { e.preventDefault(); table.ajax.reload(); }
});
});
</script>
@@ -0,0 +1,30 @@
#nullable enable
using Microsoft.AspNetCore.Mvc;
using Nop.Web.Areas.Admin.Models.Customers;
using Nop.Web.Framework.Components;
namespace Nop.Plugin.Misc.FruitBankPlugin.Components;
/// <summary>
/// „Kamu e-mail" gomb az admin vásárló-létrehozó űrlapján, az e-mail mező mellé.
/// <para>
/// A nopCommerce <c>_CreateOrUpdate.Info.cshtml</c>-jében az e-mail mező körül <b>nincs</b> widget-zóna,
/// ezért a <c>CustomerDetailsButtons</c> zónába rendelünk egy szkriptet, ami a gombot a kliens-oldalon
/// fűzi az <c>#Email</c> input mellé. Így nem kell nop-nézetet másolni/felülírni
/// (lásd <c>ACCORE 0011</c> — másolt és override-olt nopCommerce-kód).
/// </para>
/// </summary>
public class CustomerPlaceholderEmailViewComponent : NopViewComponent
{
private const string VIEW_PATH =
"~/Plugins/Misc.FruitBankPlugin/Views/Customer/PlaceholderEmailButton.cshtml";
public IViewComponentResult Invoke(string widgetZone, object additionalData)
{
// Csak ÚJ vásárlónál van értelme: meglévőnél már van azonosító és (jellemzően) cím is.
if (additionalData is not CustomerModel model || model.Id > 0)
return Content(string.Empty);
return View(VIEW_PATH);
}
}
@@ -46,6 +46,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
ICustomerRegistrationService customerRegistrationService,
ILocalizationService localizationService,
PreOrderConversionService preorderConversionService,
ShippingCleanupService shippingCleanupService,
IFruitBankEkaerService fruitBankEkaerService,
IEkaerSettings ekaerSettings,
IEnumerable<IAcLogWriterBase> logWriters)
@@ -918,6 +919,49 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
return await ctx.ShippingDocuments.GetByIdAsync(shippingDocument.Id, shippingDocument.Shipping != null || shippingDocument.Partner != null);
}
#region Adattisztító törlés (őrzött, fizikai)
// A visszatérés szándékosan string?: üres/null = mehet (illetve sikerült), egyébként az admin
// számára megjeleníthető INDOK. Így nem kell új típust bevezetni a megosztott szerelvénybe.
//
// ⚠️ A törlés-kaszkád ma is fizikai (FruitBankEventConsumer): a feltétel az egyetlen védelem,
// ezért a ShippingCleanupService a tranzakción belül újra kiértékeli. Lásd az osztály doksiját.
[SignalR(SignalRTags.CanDeleteShipping)]
public async Task<string?> CanDeleteShipping(int shippingId)
=> (await shippingCleanupService.CanDeleteShippingAsync(shippingId)).Reason;
[SignalR(SignalRTags.DeleteShipping)]
public async Task<string?> DeleteShipping(int shippingId)
{
_logger.Detail($"DeleteShipping invoked; id: {shippingId}");
return (await shippingCleanupService.DeleteShippingAsync(shippingId)).Reason;
}
[SignalR(SignalRTags.CanDeleteShippingDocument)]
public async Task<string?> CanDeleteShippingDocument(int shippingDocumentId)
=> (await shippingCleanupService.CanDeleteShippingDocumentAsync(shippingDocumentId)).Reason;
[SignalR(SignalRTags.DeleteShippingDocument)]
public async Task<string?> DeleteShippingDocument(int shippingDocumentId)
{
_logger.Detail($"DeleteShippingDocument invoked; id: {shippingDocumentId}");
return (await shippingCleanupService.DeleteShippingDocumentAsync(shippingDocumentId)).Reason;
}
[SignalR(SignalRTags.CanDeleteShippingItemPallet)]
public async Task<string?> CanDeleteShippingItemPallet(int shippingItemPalletId)
=> (await shippingCleanupService.CanDeleteShippingItemPalletAsync(shippingItemPalletId)).Reason;
[SignalR(SignalRTags.DeleteShippingItemPallet)]
public async Task<string?> DeleteShippingItemPallet(int shippingItemPalletId)
{
_logger.Detail($"DeleteShippingItemPallet invoked; id: {shippingItemPalletId}");
return (await shippingCleanupService.DeleteShippingItemPalletAsync(shippingItemPalletId)).Reason;
}
#endregion
[SignalR(SignalRTags.GetMeasuringUsers)]
public async Task<List<CustomerDto>> GetMeasuringUsers()
{
@@ -76,6 +76,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.ShipmentsList", "Shipment", "EN");
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.ShipmentsList", "Sz\u00e1ll\u00edtm\u00e1nyok", "HU");
// A lesz\u0171k\u00edtett admin-men\u00fc gy\u0171jt\u0151-csoportja: ide ker\u00fcl minden, ami a napi munk\u00e1hoz nem kell.
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.Advanced", "Advanced", "EN");
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.Advanced", "Halad\u00f3", "HU");
// ── Quick Order page ───────────────────────────────────────────────────
const string en = "EN";
const string hu = "HU";
@@ -462,6 +466,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
AdminWidgetZones.ProductDetailsBlock,
AdminWidgetZones.OrderDetailsBlock,
AdminWidgetZones.CustomerDetailsBlock,
AdminWidgetZones.CustomerDetailsButtons,
PublicWidgetZones.AccountNavigationAfter
});
}
@@ -502,6 +507,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
{
return typeof(CustomerCreditWidgetViewComponent);
}
else if (widgetZone == AdminWidgetZones.CustomerDetailsButtons)
{
return typeof(CustomerPlaceholderEmailViewComponent);
}
else if (widgetZone == PublicWidgetZones.AccountNavigationAfter)
{
return typeof(CustomerPreOrderNavViewComponent);
@@ -0,0 +1,41 @@
#nullable enable
using System.Globalization;
using System.Text.RegularExpressions;
namespace Nop.Plugin.Misc.FruitBankPlugin.Helpers;
/// <summary>
/// Helyettesítő („kamu") e-mail cím azoknak a vásárlóknak, akik nem adnak meg címet.
/// A nopCommerce vásárló-létrehozás e-mail nélkül nem enged tovább, ezért kell egy generált cím.
/// <para>Formátum: <c>invalid{customerId}@inval.id</c> — az <c>.id</c> valódi TLD, de az
/// <c>inval.id</c> nem létező domain, tehát ide soha nem megy ki levél.</para>
/// </summary>
public static class PlaceholderEmailHelper
{
private const string PREFIX = "invalid";
private const string DOMAIN = "@inval.id";
private static readonly Regex Pattern =
new($"^{PREFIX}(?<id>\\d+){Regex.Escape(DOMAIN)}$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
/// <summary>A megadott vásárló-azonosítóhoz tartozó helyettesítő cím.</summary>
public static string Build(int customerId) =>
$"{PREFIX}{customerId.ToString(CultureInfo.InvariantCulture)}{DOMAIN}";
/// <summary>Helyettesítő cím-e, és ha igen, milyen azonosítót hordoz.</summary>
public static bool TryGetCustomerId(string? email, out int customerId)
{
customerId = 0;
if (string.IsNullOrWhiteSpace(email)) return false;
var match = Pattern.Match(email.Trim());
if (!match.Success) return false;
return int.TryParse(match.Groups["id"].Value, NumberStyles.Integer,
CultureInfo.InvariantCulture, out customerId);
}
/// <summary>Helyettesítő cím-e (az azonosító értékétől függetlenül).</summary>
public static bool IsPlaceholder(string? email) => TryGetCustomerId(email, out _);
}
@@ -83,6 +83,18 @@ public class PluginNopStartup : INopStartup
services.AddSingleton<ILockService, LockService>();
services.AddScoped<FruitBankAttributeService>();
// A termék-törzsadat egyetlen írási/olvasási határa (ADR 0001-V4T7) — a tárolás cseréje
// ennek az osztálynak a belsejét érinti, a hívóit nem.
services.AddScoped<Services.ProductMasterDataService>();
// Őrzött, fizikai adattisztító törlés a Hybrid gridjéhez (a törlés-kaszkád ma is fizikai,
// ezért a feltétel a tranzakción belül újra kiértékelődik).
services.AddScoped<Services.ShippingCleanupService>();
// Admin-menü profil: MELY menüpontok maradnak a főmenüben (a többi a „Haladó" alá kerül).
// A szerep-függő menü belépési pontja — akkor ezt az implementációt cseréljük, a fa-formázó marad.
services.AddScoped<Services.Menu.IAdminMenuProfileProvider, Services.Menu.AdminMenuProfileProvider>();
services.AddScoped<ProductDtoDbTable>();
services.AddScoped<OrderDtoDbTable>();
services.AddScoped<OrderItemDtoDbTable>();
@@ -236,6 +236,9 @@
<None Update="Areas\Admin\Views\PreOrder\List.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\ProductMasterData\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\Product\List.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
@@ -686,6 +689,9 @@
<None Update="Views\CustomerPreOrder\NavItem.cshtml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Views\Customer\PlaceholderEmailButton.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Views\Help\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
@@ -1,4 +1,5 @@
using FruitBank.Common.Enums;
using Nop.Plugin.Misc.FruitBankPlugin.Services.Menu;
using FruitBank.Common.Interfaces;
using FruitBank.Common.Server;
using Mango.Nop.Core.Dtos;
@@ -14,6 +15,7 @@ using Nop.Core.Domain.Orders;
using Nop.Core.Domain.Tax;
using Nop.Core.Events;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Helpers;
using Nop.Plugin.Misc.FruitBankPlugin.Models;
using Nop.Plugin.Misc.FruitBankPlugin.Models.Orders;
using Nop.Services.Attributes;
@@ -44,6 +46,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
private readonly IWorkContext _workContext;
private readonly IStoreContext _storeContext;
private readonly IAdminMenu _adminMenu;
private readonly IAdminMenuProfileProvider _adminMenuProfileProvider;
private readonly ILocalizationService _localizationService;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IAddressService _addressService;
@@ -64,6 +67,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
IWorkContext workContext,
IStoreContext storeContext,
IAdminMenu adminMenu,
IAdminMenuProfileProvider adminMenuProfileProvider,
ILocalizationService localizationService,
IHttpContextAccessor httpContextAccessor,
IAddressService addressService,
@@ -83,6 +87,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
_workContext = workContext;
_storeContext = storeContext;
_adminMenu = adminMenu;
_adminMenuProfileProvider = adminMenuProfileProvider;
_localizationService = localizationService;
_httpContextAccessor = httpContextAccessor;
_addressService = addressService;
@@ -277,6 +282,17 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
Url = _adminMenu.GetMenuItemUrl("PreOrderAdmin", "List")
};
var productMasterDataMenuItem = new AdminMenuItem
{
Visible = true,
SystemName = "FruitBank",
Title = "Termék-törzsadat",
IconClass = "fas fa-table",
Url = _adminMenu.GetMenuItemUrl("ProductMasterData", "Index")
};
shippingConfigurationItem.ChildNodes.Insert(5, productMasterDataMenuItem);
var preordersRootMenuItem = new AdminMenuItem
{
Visible = true,
@@ -347,6 +363,23 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
invoiceConfigurationItem.ChildNodes.Insert(2, invoicesMenuItem);
await InsertAiConfigurationMenuItemAsync(rootNode);
// MINDEN saját beszúrás UTÁN fut: a főmenü leszűkítése a profil szerint, a többi a „Haladó" alá.
// Fehérlistás — ami nincs a profilban, az odakerül —, így egy nop-frissítés új menüpontja sem
// szivárog vissza a főmenübe. Semmi nem vész el: a Haladó alatt minden elérhető marad.
AdminMenuShaper.Apply(
rootNode,
await _adminMenuProfileProvider.GetForCurrentCustomerAsync(),
await _localizationService.GetResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.Advanced"));
}
/// <summary>Az „AI" almenü beszúrása a Konfiguráció alá.
/// <para>Külön metódus, mert korai <c>return</c>-jei vannak: a hívó <c>HandleEventAsync</c>-ben maradva
/// csendben kihagyták volna a menü profil szerinti átrendezését — és bármit, amit később valaki a
/// handler végére tesz.</para></summary>
private async Task InsertAiConfigurationMenuItemAsync(AdminMenuItem rootNode)
{
var configurationItem = rootNode.ChildNodes.FirstOrDefault(node => node.SystemName.Equals("Configuration"));
if (configurationItem is null)
return;
@@ -388,9 +421,44 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
public async Task HandleEventAsync(EntityInsertedEvent<Customer> eventMessage)
{
await FixPlaceholderEmailAsync(eventMessage?.Entity);
await ProcessCustomerEventAsync(eventMessage?.Entity);
}
/// <summary>
/// A „kamu" e-mail címet a <b>tényleges</b> vásárló-azonosítóra igazítja.
/// <para>
/// Az admin űrlapján a gomb csak <b>becsülni</b> tudja az azonosítót (a legnagyobb létező
/// <c>Customer.Id</c> + 1), mert a cím a mentés ELŐTT kell — e-mail nélkül a nop nem enged tovább.
/// A becslés viszont elcsúszhat: a nopCommerce a bolt <b>vendég-látogatóinak</b> is <c>Customer</c>
/// sort hoz létre, tehát a gombnyomás és a mentés között elfogyhat a megjósolt azonosító.
/// Itt már ismerjük a valódit, ezért ez az egyetlen hely, ahol a cím biztosan helyes lehet.
/// </para>
/// Csak akkor nyúlunk hozzá, ha a cím a mi mintánk (<c>invalid{szám}@inval.id</c>) ÉS a benne lévő
/// azonosító eltér a valóditól — valódi e-mail címet soha nem írunk át.
/// </summary>
private async Task FixPlaceholderEmailAsync(Customer customer)
{
if (customer == null || customer.Id <= 0) return;
if (!PlaceholderEmailHelper.TryGetCustomerId(customer.Email, out var embeddedId)) return;
if (embeddedId == customer.Id) return;
var corrected = PlaceholderEmailHelper.Build(customer.Id);
// Elvi ütközés-védelem: ha valamiért már létezik ez a cím, hagyjuk a becsültet — a duplikált
// e-mail a nopCommerce bejelentkezési logikáját zavarná meg.
if (await _customerService.GetCustomerByEmailAsync(corrected) != null)
{
// A már injektált DbContext logger-én át — ebben az osztályban nincs saját Logger tagja.
_dbContext.Logger.Warning($"[EventConsumer] A helyettesítő e-mail nem igazítható: '{corrected}' már foglalt (customerId: {customer.Id}, marad: '{customer.Email}').");
return;
}
customer.Email = corrected;
await _customerService.UpdateCustomerAsync(customer);
}
public async Task HandleEventAsync(EntityUpdatedEvent<Customer> eventMessage)
{
await ProcessCustomerEventAsync(eventMessage?.Entity);
@@ -128,6 +128,16 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services.FileStorage
await streamToSave.DisposeAsync();
}
// ✅ STEP 6: Persist the storage path.
// The record is inserted BEFORE the path is known (the path embeds the new Id), and the
// ".gz" suffix is only decided above — so this is the first point where the final path
// exists. Historically this was never written back, leaving FileSubPath null on every row
// and making the blob unlocatable at delete time (the leading path segment is the
// uploading user's id, which is recorded nowhere else).
fileEntity.FileSubPath = relativePath;
fileEntity.Modified = DateTime.UtcNow;
await _dbContext.Files.UpdateAsync(fileEntity);
Console.WriteLine($"💾 File saved: {relativePath} (Compressed: {shouldCompress})");
return fileEntity;
}
@@ -318,6 +328,59 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services.FileStorage
return true;
}
/// <summary>
/// Resolves the storage path of a file record, preferring the stored <see cref="Files.FileSubPath"/>.
/// <para>
/// Older records have no stored path (it was never written back on save), so we fall back to a
/// recursive name lookup — unambiguous because the stored name embeds the record Id. When the
/// lookup succeeds the path is written back, so the gap heals record by record without a migration.
/// </para>
/// </summary>
/// <returns>Relative path, or null when the blob cannot be located.</returns>
public async Task<string> ResolveStoragePathAsync(Files fileEntity)
{
if (fileEntity == null) return null;
if (!string.IsNullOrWhiteSpace(fileEntity.FileSubPath))
return fileEntity.FileSubPath;
var storedFileName = $"{fileEntity.FileName}_{fileEntity.Id}{fileEntity.FileExtension}";
if (!IsAlreadyCompressed(fileEntity.FileExtension))
storedFileName += ".gz";
var foundPath = await _storageProvider.FindFileByNameAsync(storedFileName);
if (string.IsNullOrWhiteSpace(foundPath)) return null;
fileEntity.FileSubPath = foundPath;
fileEntity.Modified = DateTime.UtcNow;
await _dbContext.Files.UpdateAsync(fileEntity);
return foundPath;
}
/// <summary>
/// Deletes a file record and its blob without needing the original upload coordinates.
/// <para>
/// The database row is removed even when the blob cannot be located, because the re-import
/// idempotency check keys on <see cref="Files.FileHash"/> — leaving the row behind would keep the
/// document permanently un-reimportable. A missing blob is logged instead.
/// </para>
/// </summary>
public async Task<bool> DeleteFileWithBlobAsync(Files fileEntity)
{
if (fileEntity == null) return false;
var relativePath = await ResolveStoragePathAsync(fileEntity);
if (string.IsNullOrWhiteSpace(relativePath))
Console.Error.WriteLine($"⚠️ File blob not found for Files.Id {fileEntity.Id} ('{fileEntity.FileName}{fileEntity.FileExtension}'); deleting the database record only.");
else
await _storageProvider.DeleteFileAsync(relativePath);
await _dbContext.Files.DeleteAsync(fileEntity);
return true;
}
#region Private Helper Methods
/// <summary>
@@ -34,6 +34,9 @@ public class FruitBankNotificationService(
IQueuedEmailService queuedEmailService,
IStoreService storeService,
ITokenizer tokenizer,
// A nop natív naplózója: a bejegyzések az Admin → Rendszer → Napló alatt megjelennek. Enélkül egy elmaradt
// vevői értesítés nyomtalan maradna — élesben pontosan ez rejtett el egy üres törzsű sablont.
Nop.Services.Logging.ILogger logger,
MessagesSettings messagesSettings) : WorkflowMessageService(commonSettings,
emailAccountSettings,
addressService,
@@ -168,17 +171,48 @@ public class FruitBankNotificationService(
var templates = await messageTemplateService.GetMessageTemplatesByNameAsync(templateName, store.Id);
var messageTemplate = templates?.FirstOrDefault();
if (messageTemplate is null || !messageTemplate.IsActive)
// A hiányzó/inaktív sablon eddig NÉMÁN nyelte el az értesítést (`return 0`, nyom nélkül). Egy vevőnek szóló
// levél elmaradása nem maradhat láthatatlan — a naplóból utólag is kideríthető.
if (messageTemplate is null)
{
await logger.WarningAsync(
$"FruitBank értesítés kimaradt: a(z) '{templateName}' üzenetsablon nem létezik (store: {store.Id}; rendelés: #{order.Id}). " +
"A sablonokat a plugin InstallAsync-je hozza létre, ami már telepített pluginnál nem fut le újra — ilyenkor kézzel kell pótolni.");
return 0;
}
if (!messageTemplate.IsActive)
{
await logger.WarningAsync(
$"FruitBank értesítés kimaradt: a(z) '{templateName}' üzenetsablon INAKTÍV (store: {store.Id}; rendelés: #{order.Id}).");
return 0;
}
var emailAccount = await emailAccountService.GetEmailAccountByIdAsync(messageTemplate.EmailAccountId)
?? await emailAccountService.GetEmailAccountByIdAsync(emailAccountSettings.DefaultEmailAccountId);
if (emailAccount is null)
{
await logger.ErrorAsync(
$"FruitBank értesítés kimaradt: nincs használható e-mail fiók a(z) '{templateName}' sablonhoz " +
$"(sablon fiókja: {messageTemplate.EmailAccountId}; alapértelmezett: {emailAccountSettings.DefaultEmailAccountId}; rendelés: #{order.Id}).");
return 0;
}
var tokens = new List<Token>();
await messageTokenProvider.AddStoreTokensAsync(tokens, store, emailAccount, order.CustomerLanguageId);
await messageTokenProvider.AddOrderTokensAsync(tokens, order, order.CustomerLanguageId);
var customer = await customerService.GetCustomerByIdAsync(order.CustomerId);
// Törölt/hiányzó vevőnél nincs kinek küldeni — eddig a lentebbi customer.Email-nél szállt el.
if (customer is null)
{
await logger.ErrorAsync(
$"FruitBank értesítés kimaradt: a(z) #{order.Id} rendelés vevője nem található (customerId: {order.CustomerId}; sablon: '{templateName}').");
return 0;
}
await messageTokenProvider.AddCustomerTokensAsync(tokens, customer);
tokens.Add(new Token("Order.MeasurableNote", measurableNote, true));
@@ -195,8 +229,6 @@ public class FruitBankNotificationService(
customerEmail = (await addressService.GetAddressByIdAsync(addressId)).Email ?? customer.Email;
}
Console.WriteLine($"Customer email determined as: {customerEmail} (addressId: {addressId})");
var toName = $"{customer.FirstName} {customer.LastName}".Trim();
if (string.IsNullOrWhiteSpace(toName)) toName = customer.Email;
@@ -36,5 +36,18 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services.FileStorage
/// <param name="relativePath">Relative path from storage root</param>
/// <returns>True if file exists</returns>
Task<bool> FileExistsAsync(string relativePath);
/// <summary>
/// Recursively looks up a file by its exact file name and returns its path relative to the storage root.
/// <para>
/// Needed because <c>Files.FileSubPath</c> was never populated historically, so for older records the
/// storage path cannot be rebuilt: the first path segment is the uploading user's id, which is not
/// recorded anywhere. The stored file name embeds the <c>Files.Id</c>
/// (<c>{FileName}_{Id}{ext}[.gz]</c>), so a name lookup is unambiguous.
/// </para>
/// </summary>
/// <param name="fileName">Exact file name including extension (and the .gz suffix when compressed)</param>
/// <returns>Relative path, or null when not found</returns>
Task<string> FindFileByNameAsync(string fileName);
}
}
@@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services.FileStorage
@@ -111,6 +112,26 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services.FileStorage
return Task.FromResult(File.Exists(fullPath));
}
/// <summary>
/// Recursively looks up a file by its exact name under the storage root.
/// The stored name embeds the Files.Id, so at most one match is expected; if several turn up
/// (should not happen), the first is returned deterministically by ordinal ordering.
/// </summary>
public Task<string> FindFileByNameAsync(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName) || !Directory.Exists(_baseDirectory))
return Task.FromResult<string>(null);
var match = Directory
.EnumerateFiles(_baseDirectory, fileName, SearchOption.AllDirectories)
.OrderBy(p => p, StringComparer.Ordinal)
.FirstOrDefault();
if (match == null) return Task.FromResult<string>(null);
return Task.FromResult(Path.GetRelativePath(_baseDirectory, match));
}
/// <summary>
/// Cleans up empty parent directories after file deletion
/// </summary>
@@ -200,11 +200,16 @@ public class MeasurementService : MeasurementServiceBase<Logger>, IMeasurementSe
averageWeight = TotalAverageWeight;
}
//TODO ELSE - we have to get the net weight from the document, not from measuring
//TODO ELSE - we have to get the net weight from the document, not from measuring
else
{
// Érvénytelen raklapból nincs mit tanulni — a törzsadatot békén hagyjuk.
// (A jelenlegi érték visszaírása 0-s sort SZÚRNA BE annak, akinek még nincs.)
return;
}
await SetProductAverageWeight(productDto, averageWeight);
await _dbContext.ProductDtos.UpdateAsync(productDto);
//await _dbContext.ProductDtos.UpdateAsync(productDto);
}
}
@@ -0,0 +1,95 @@
using Nop.Core;
using Nop.Services.Customers;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services.Menu
{
/// <summary>
/// Egy admin-menü profil: MELY menüpontok maradnak a főmenüben. Minden más a „Haladó" alá kerül.
/// <para>A profil szándékosan ADAT, nem logika: a szerep-függő menü később annyi, hogy az
/// <see cref="IAdminMenuProfileProvider"/> más profilt ad vissza — a fát formázó
/// <see cref="AdminMenuShaper"/> nem változik.</para>
/// </summary>
public sealed class AdminMenuProfile
{
/// <summary>A profil azonosítója (naplózáshoz, később szerep-hozzárendeléshez).</summary>
public required string SystemName { get; init; }
/// <summary>A főmenüben maradó menüpontok <c>SystemName</c>-jei.
/// <para>FEHÉRLISTA, nem feketelista: ami nincs benne, az a „Haladó" alá kerül. Így egy nopCommerce-frissítés
/// új menüpontja NEM szivárog vissza a főmenübe — feketelistával minden verzióváltásnál újra takarítanánk.</para>
/// <para>Ha egy CSOPORT neve szerepel benne, a csoport teljes egészében kint marad; ha csak egy LEVÉL neve,
/// a csoport kint marad, de kizárólag azzal a levéllel.</para></summary>
public required IReadOnlySet<string> Keep { get; init; }
}
/// <summary>A nopCommerce beépített admin-menüpontjainak <c>SystemName</c>-jei (<c>Nop.Web.Framework/Menu/AdminMenu.cs</c>).
/// Azért konstansok, hogy elgépelés ne csússzon be, és egy nop-frissítéskor egy helyen legyen ellenőrizhető.</summary>
public static class NopAdminMenuSystemNames
{
public const string Dashboard = "Dashboard";
public const string Catalog = "Catalog";
public const string Products = "Products";
public const string Categories = "Categories";
public const string Sales = "Sales";
public const string Orders = "Orders";
public const string Customers = "Customers";
/// <summary>Szóközzel — a nop így definiálja.</summary>
public const string CustomersList = "Customers list";
public const string Reports = "Reports";
}
/// <summary>Megmondja, melyik menü-profil vonatkozik az aktuális admin felhasználóra.
/// <para><c>null</c> = ne alakítsuk a menüt (teljes nopCommerce-fa). Ez a szándékos kiskapu: egy későbbi
/// fejlesztői/üzemeltetői szerep így a teljes menüt kapja, a formázó kód módosítása nélkül.</para></summary>
public interface IAdminMenuProfileProvider
{
Task<AdminMenuProfile?> GetForCurrentCustomerAsync();
}
/// <summary>Mai állapot: MINDEN admin ugyanazt a szűkített menüt kapja.
/// <para>A szerep-függőség belépési pontja ez az osztály: a <see cref="GetForCurrentCustomerAsync"/> a
/// <see cref="IWorkContext"/>-ből kiolvasott szerep alapján fog profilt választani. A profilok maguk adatok,
/// tehát új szerep = új <see cref="AdminMenuProfile"/>, nem új logika.</para></summary>
public class AdminMenuProfileProvider : IAdminMenuProfileProvider
{
protected readonly IWorkContext _workContext;
protected readonly ICustomerService _customerService;
public AdminMenuProfileProvider(IWorkContext workContext, ICustomerService customerService)
{
_workContext = workContext;
_customerService = customerService;
}
/// <summary>A nagykereskedelmi admin napi munkájához elég menü. Amit nem sorolunk fel, azt a
/// „Haladó" alatt továbbra is eléri — semmi nem vész el, csak egy kattintással messzebb kerül.</summary>
public static readonly AdminMenuProfile FruitBankAdmin = new()
{
SystemName = "FruitBankAdmin",
Keep = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
NopAdminMenuSystemNames.Dashboard, // a saját dashboardunk
NopAdminMenuSystemNames.Products, // Katalógus > Termékek
NopAdminMenuSystemNames.Categories, // Katalógus > Kategóriák
NopAdminMenuSystemNames.Orders, // Eladások > Rendelések
NopAdminMenuSystemNames.CustomersList, // Vásárlók > Vásárlók listája
FruitBankMenuSystemName // a saját menüpontjaink
}
};
/// <summary>A plugin saját menüpontjainak közös <c>SystemName</c>-je (lásd <c>EventConsumer</c>).</summary>
public const string FruitBankMenuSystemName = "FruitBank";
public virtual Task<AdminMenuProfile?> GetForCurrentCustomerAsync()
{
// Ma szerep-független. A bővítés helye: a szerep kiolvasása és profil-választás — a hívó és a
// fa-formázó ettől nem változik.
return Task.FromResult<AdminMenuProfile?>(FruitBankAdmin);
}
}
}
@@ -0,0 +1,101 @@
using Nop.Web.Framework.Menu;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services.Menu
{
/// <summary>
/// Az admin-menüfa átrendezése egy <see cref="AdminMenuProfile"/> szerint: a profilban felsorolt menüpontok
/// maradnak a főmenüben, MINDEN MÁS egyetlen „Haladó" csoport alá kerül — az eredeti csoport-szerkezetét
/// megtartva, hogy az admin tudja, hol keresse.
/// <para><b>Semmi nem vész el és semmi nem duplikálódik:</b> a meglévő csomópontokat MOZGATJUK, nem másoljuk.
/// Így a jogosultság-szűrés, a lokalizált címek, az URL-ek és az ikonok változatlanul működnek — az
/// <see cref="AdminMenuItem.PermissionNames"/> ráadásul a gyerekekből származik, tehát a mozgatással
/// automatikusan helyes marad.</para>
/// </summary>
public static class AdminMenuShaper
{
/// <summary>A „Haladó" gyűjtő-csomópont azonosítója.</summary>
public const string AdvancedSystemName = "FruitBank.Advanced";
/// <summary>A Haladó alá került csoport-másolatok <c>SystemName</c>-toldaléka. Azért kell, mert az eredeti
/// csoport a főmenüben is megmaradhat (a kint tartott leveleivel), és két azonos <c>SystemName</c> összezavarná
/// a <see cref="AdminMenuItem.GetItemBySystem"/> keresést.</summary>
public const string AdvancedSuffix = ".Advanced";
/// <summary>Tartalék cím. A lokalizált erőforrás az <c>InstallAsync</c>-ben jön létre, ami MÁR TELEPÍTETT
/// pluginnál nem fut le újra — enélkül a menüpont üres vagy nyers erőforrás-kulcs néven jelenne meg.</summary>
public const string DefaultAdvancedTitle = "Haladó";
/// <summary>Átrendezi a fát a profil szerint. <paramref name="profile"/> = <c>null</c> esetén nem nyúl hozzá
/// (teljes nopCommerce-menü).</summary>
/// <param name="root">Az <c>AdminMenuCreatedEvent.RootMenuItem</c> (a „Home" csomópont).</param>
/// <param name="profile">A alkalmazandó profil, vagy <c>null</c>.</param>
/// <param name="advancedTitle">A „Haladó" csoport lokalizált címe.</param>
/// <returns>Hány felső szintű csoport került a Haladó alá (naplózáshoz).</returns>
public static int Apply(AdminMenuItem root, AdminMenuProfile? profile, string advancedTitle)
{
if (root == null || profile == null) return 0;
var advanced = new AdminMenuItem
{
Visible = true,
SystemName = AdvancedSystemName,
Title = string.IsNullOrWhiteSpace(advancedTitle) || advancedTitle.StartsWith("Plugins.", StringComparison.Ordinal)
? DefaultAdvancedTitle
: advancedTitle,
IconClass = "fas fa-sliders-h"
};
var keptAtTop = new List<AdminMenuItem>();
foreach (var group in root.ChildNodes.ToList())
{
// A csoport egésze kint marad (pl. a saját menüpontjaink, a Dashboard).
if (profile.Keep.Contains(group.SystemName ?? string.Empty))
{
keptAtTop.Add(group);
continue;
}
var keptChildren = group.ChildNodes
.Where(child => profile.Keep.Contains(child.SystemName ?? string.Empty))
.ToList();
// Egyetlen gyereke sem kell (vagy nincs is gyereke, és maga sem szerepel) → az egész csoport megy.
if (keptChildren.Count == 0)
{
advanced.ChildNodes.Add(group);
continue;
}
var movedChildren = group.ChildNodes.Except(keptChildren).ToList();
group.ChildNodes = keptChildren;
keptAtTop.Add(group);
// A csoport maradéka a Haladó alatt IS a saját csoportja alatt jelenik meg (Katalógus → Gyártók, …),
// hogy a megszokott hely felismerhető maradjon.
if (movedChildren.Count > 0)
advanced.ChildNodes.Add(CloneGroupForAdvanced(group, movedChildren));
}
root.ChildNodes = keptAtTop;
if (advanced.ChildNodes.Count > 0)
root.ChildNodes.Add(advanced);
return advanced.ChildNodes.Count;
}
/// <summary>A főmenüben maradó csoport „árnyék-másolata" a Haladó alá, a kint nem tartott gyerekeivel.
/// Csak a megjelenítéshez kell (cím + ikon); az URL-t és a jogosultságokat szándékosan nem másoljuk —
/// utóbbi a gyerekekből származik.</summary>
private static AdminMenuItem CloneGroupForAdvanced(AdminMenuItem group, IList<AdminMenuItem> children) => new()
{
Visible = group.Visible,
SystemName = (group.SystemName ?? string.Empty) + AdvancedSuffix,
Title = group.Title,
IconClass = group.IconClass,
ChildNodes = children
};
}
}
@@ -0,0 +1,317 @@
#nullable enable
using System.Globalization;
using FruitBank.Common.Dtos;
using FruitBank.Common.Interfaces;
using LinqToDB;
using Nop.Core;
using Nop.Core.Domain.Catalog;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
/// <summary>
/// A termék mérési/logisztikai törzsadatának egyetlen írási/olvasási határa —
/// <c>IsMeasurable</c>, <c>Tare</c>, <c>AverageWeight</c>, <c>AverageWeightTreshold</c>, <c>CratesPerPallet</c>.
/// <para>
/// Ez a réteg szándékosan elrejti, hogy az értékek ma <c>GenericAttribute</c> kulcs-érték sorok. Az
/// <c>ADR 0001-V4T7</c> döntése szerint ezek elsőrendű oszloppá válnak egy plugin-tulajdonú táblában;
/// a váltás ennek az osztálynak a belsejét cseréli, a hívóit (admin felület, nop termék-oldal) nem.
/// </para>
/// <para>
/// ⚠️ A <c>NetWeight</c> és az <c>IncomingQuantity</c> <b>nem</b> tartozik ide: azok vezérelt értékek
/// (készlet-/mérési lánc, illetve beérkezés írja őket), kézzel nem szerkeszthetők.
/// </para>
/// </summary>
public sealed class ProductMasterDataService(
FruitBankDbContext ctx,
FruitBankAttributeService attributeService,
IStoreContext storeContext)
{
/// <summary>A <c>GenericAttribute.KeyGroup</c> értéke a termék-attribútumokhoz.</summary>
private const string PRODUCT_KEY_GROUP = nameof(Product);
public const string KEY_IS_MEASURABLE = nameof(IMeasurable.IsMeasurable);
public const string KEY_TARE = nameof(ITare.Tare);
public const string KEY_AVERAGE_WEIGHT = nameof(IProductDto.AverageWeight);
public const string KEY_AVERAGE_WEIGHT_TRESHOLD = nameof(IProductDto.AverageWeightTreshold);
public const string KEY_CRATES_PER_PALLET = nameof(IProductDto.CratesPerPallet);
private static readonly string[] AllKeys =
[
KEY_IS_MEASURABLE, KEY_TARE, KEY_AVERAGE_WEIGHT, KEY_AVERAGE_WEIGHT_TRESHOLD, KEY_CRATES_PER_PALLET
];
#region Read
/// <summary>
/// A rács adatforrása. A szűrés/rendezés/lapozás azért fut memóriában, mert a „hiányos törzsadat"
/// feltétel a mai kulcs-érték tárolásban termékenként öt sor összeolvasását kívánja — pontosan az a
/// költség, amit az ADR 0001-V4T7 a tipizált oszlopokkal megszüntetne. A projekció szándékosan sovány
/// (Id/Name/Sku + öt érték), így néhány ezer termék is elfér.
/// </summary>
public async Task<(List<ProductMasterDataRow> Rows, int TotalCount, int FilteredCount)> SearchAsync(
string? search, bool onlyIncomplete, int skip, int take, string? sortField, bool sortDescending)
{
var products = await ctx.ProductDtos.GetAll(false)
.Select(p => new { p.Id, p.Name, p.Sku })
.ToListAsync();
var totalCount = products.Count;
if (!string.IsNullOrWhiteSpace(search))
{
var term = search.Trim();
products = products
.Where(p => (p.Name != null && p.Name.Contains(term, StringComparison.OrdinalIgnoreCase))
|| (p.Sku != null && p.Sku.Contains(term, StringComparison.OrdinalIgnoreCase)))
.ToList();
}
var productIds = products.Select(p => p.Id).ToList();
var valuesByProduct = await GetValuesAsync(productIds);
var rows = products
.Select(p =>
{
var v = valuesByProduct.TryGetValue(p.Id, out var found) ? found : ProductMasterDataValues.Empty;
return new ProductMasterDataRow
{
Id = p.Id,
Name = p.Name ?? string.Empty,
Sku = p.Sku ?? string.Empty,
Values = v
};
})
.Where(r => !onlyIncomplete || r.Values.IsIncomplete)
.ToList();
var filteredCount = rows.Count;
rows = Sort(rows, sortField, sortDescending);
if (skip > 0) rows = rows.Skip(skip).ToList();
if (take > 0) rows = rows.Take(take).ToList();
return (rows, totalCount, filteredCount);
}
private static List<ProductMasterDataRow> Sort(List<ProductMasterDataRow> rows, string? sortField, bool desc)
{
Func<ProductMasterDataRow, object?> key = sortField switch
{
nameof(ProductMasterDataRow.Name) => r => r.Name,
nameof(ProductMasterDataRow.Sku) => r => r.Sku,
KEY_IS_MEASURABLE => r => r.Values.IsMeasurable,
KEY_TARE => r => r.Values.Tare,
KEY_AVERAGE_WEIGHT => r => r.Values.AverageWeight,
KEY_AVERAGE_WEIGHT_TRESHOLD => r => r.Values.AverageWeightTreshold,
KEY_CRATES_PER_PALLET => r => r.Values.CratesPerPallet,
_ => r => r.Id
};
return (desc ? rows.OrderByDescending(key) : rows.OrderBy(key)).ToList();
}
/// <summary>Egyetlen termék törzsadata.</summary>
public async Task<ProductMasterDataValues> GetAsync(int productId)
{
var map = await GetValuesAsync([productId]);
return map.TryGetValue(productId, out var v) ? v : ProductMasterDataValues.Empty;
}
/// <summary>
/// Az öt érték kiolvasása több termékre, egyetlen lekérdezéssel.
/// A <b>hiányzó sort</b> és a <b>0 értékű sort</b> megkülönbözteti (a <c>null</c> = „nincs beállítva") —
/// szemben a <c>ProductDto</c> propertyivel, amelyek <c>GetValueOrDefault</c>-tal összemossák a kettőt.
/// </summary>
private async Task<Dictionary<int, ProductMasterDataValues>> GetValuesAsync(IReadOnlyCollection<int> productIds)
{
var result = new Dictionary<int, ProductMasterDataValues>();
if (productIds.Count == 0) return result;
var attributes = await ctx.GenericAttributeDtos.Table
.Where(ga => ga.KeyGroup == PRODUCT_KEY_GROUP
&& AllKeys.Contains(ga.Key)
&& productIds.Contains(ga.EntityId))
.Select(ga => new { ga.EntityId, ga.Key, ga.Value })
.ToListAsync();
foreach (var group in attributes.GroupBy(a => a.EntityId))
{
string? Raw(string key) => group.FirstOrDefault(a => a.Key == key)?.Value;
result[group.Key] = new ProductMasterDataValues
{
IsMeasurable = ParseBool(Raw(KEY_IS_MEASURABLE)),
Tare = ParseDouble(Raw(KEY_TARE)),
AverageWeight = ParseDouble(Raw(KEY_AVERAGE_WEIGHT)),
AverageWeightTreshold = ParseDouble(Raw(KEY_AVERAGE_WEIGHT_TRESHOLD)),
CratesPerPallet = ParseInt(Raw(KEY_CRATES_PER_PALLET))
};
}
return result;
}
#endregion
#region Write
/// <summary>
/// Egy termék törzsadatának mentése. A <c>null</c> érték <b>törli</b> az attribútumot — így a
/// „nincs beállítva" állapot visszaállítható, és nem ragad be egy 0 értékű sor.
/// </summary>
/// <returns>A validációs hibák listája; üres lista = sikeres mentés.</returns>
public async Task<List<string>> SaveAsync(int productId, ProductMasterDataValues values)
{
var errors = Validate(productId, values);
if (errors.Count > 0) return errors;
var storeId = storeContext.GetCurrentStore().Id;
await WriteAsync<bool>(productId, KEY_IS_MEASURABLE, values.IsMeasurable, storeId);
await WriteAsync<double>(productId, KEY_TARE, values.Tare, storeId);
await WriteAsync<double>(productId, KEY_AVERAGE_WEIGHT, values.AverageWeight, storeId);
await WriteAsync<double>(productId, KEY_AVERAGE_WEIGHT_TRESHOLD, values.AverageWeightTreshold, storeId);
await WriteAsync<int>(productId, KEY_CRATES_PER_PALLET, values.CratesPerPallet, storeId);
return errors;
}
private async Task WriteAsync<T>(int productId, string key, T? value, int storeId) where T : struct
{
if (value is null)
{
await attributeService.DeleteGenericAttributeAsync<Product>(productId, key, storeId);
return;
}
await attributeService.InsertOrUpdateGenericAttributeAsync<Product, T>(productId, key, value.Value, storeId);
}
/// <summary>
/// A validáció itt él, és <b>visszaadja</b> a hibát. (A nop termék-oldal régi útja,
/// a <c>FruitBankEventConsumer.SaveProductCustomAttributesAsync</c>, naplóba nyelte — lásd ADR 0001-V4T7.)
/// </summary>
private static List<string> Validate(int productId, ProductMasterDataValues values)
{
var errors = new List<string>();
if (productId <= 0) errors.Add("Érvénytelen termékazonosító.");
if (values.Tare is < 0) errors.Add("A tára nem lehet negatív.");
if (values.AverageWeight is < 0) errors.Add("Az átlagsúly nem lehet negatív.");
if (values.AverageWeightTreshold is < 0) errors.Add("A küszöb nem lehet negatív.");
if (values.CratesPerPallet is < 0) errors.Add("A rekesz/raklap nem lehet negatív.");
// A küszöb 0-ja nem „szigorúbb ellenőrzés", hanem tartós érvénytelenség: az
// OrderItemDto.AverageWeightIsValid a `Math.Abs(eltérés%) < küszöb` összehasonlításon áll.
if (values.IsMeasurable == true && values.AverageWeightTreshold is 0)
errors.Add("Mérendő terméknél a küszöb 0 értéke minden tételt érvénytelenné tesz — adj meg pozitív százalékot, vagy hagyd üresen.");
return errors;
}
#endregion
#region Input parsing (a rácsból érkező nyers szöveg)
/// <summary>
/// A felületről érkező szám értelmezése. Szándékosan <b>nem</b> az MVC model-bindingre bízzuk:
/// az a kérés <b>aktuális kultúráját</b> használja (magyar boltnál tizedes<b>vessző</b>), miközben a
/// tárolás a <c>CommonHelper.To&lt;T&gt;</c> révén <b>InvariantCulture</b> (tizedes<b>pont</b>).
/// A kettő eltérése esetén a <c>double?</c> paraméter némán <c>null</c>-ra bindolódna — a <c>null</c>
/// pedig itt „nincs beállítva" = <b>törlés</b>, tehát egy mentés kinullázta volna a meglévő adatot is.
/// <para>Vesszőt és pontot egyaránt elfogad. Az <b>üres</b> szöveg érvényes: az a „nincs beállítva".</para>
/// </summary>
/// <returns><c>false</c>, ha a szöveg nem üres, de nem is értelmezhető szám — ilyenkor NEM szabad írni.</returns>
public static bool TryParseOptionalDouble(string? raw, out double? value)
{
value = null;
if (string.IsNullOrWhiteSpace(raw)) return true;
if (!double.TryParse(raw.Trim().Replace(',', '.'), NumberStyles.Float,
CultureInfo.InvariantCulture, out var parsed))
return false;
value = parsed;
return true;
}
/// <inheritdoc cref="TryParseOptionalDouble"/>
public static bool TryParseOptionalInt(string? raw, out int? value)
{
value = null;
if (string.IsNullOrWhiteSpace(raw)) return true;
if (!int.TryParse(raw.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
return false;
value = parsed;
return true;
}
#endregion
#region Stored-value parsing
private static bool? ParseBool(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
return bool.TryParse(raw, out var b) ? b : raw.Trim() == "1";
}
private static double? ParseDouble(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
return CommonHelper.To<double>(raw);
}
private static int? ParseInt(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
return CommonHelper.To<int>(raw);
}
#endregion
}
/// <summary>A termék öt törzsadat-értéke. A <c>null</c> jelentése mindenütt: „nincs beállítva".</summary>
public sealed record ProductMasterDataValues
{
public static readonly ProductMasterDataValues Empty = new();
public bool? IsMeasurable { get; init; }
public double? Tare { get; init; }
public double? AverageWeight { get; init; }
public double? AverageWeightTreshold { get; init; }
public int? CratesPerPallet { get; init; }
/// <summary>
/// A „hiányos törzsadat" szabálya (ADR 0001-V4T7, Decision 9.):
/// <list type="bullet">
/// <item><c>Tare</c> — csak a <b>hiányzó sor</b> számít hiánynak; a 0 legitim
/// (<c>MeasuringItemPalletBase.IsValidMeasuringValues</c>: <c>TareWeight &gt;= 0</c>).</item>
/// <item><c>AverageWeight</c> — hiány vagy ≤ 0; az <c>AverageWeightIsValid</c> <c>&gt; 0</c>-t követel.</item>
/// <item><c>CratesPerPallet</c> — hiány vagy ≤ 0; 0-val nem származtatható raklapszám (ADR 0004-M9F3).</item>
/// <item><c>AverageWeightTreshold</c> — <b>csak mérendő terméknél</b>; hiányzó/0 küszöbnél a
/// <c>Math.Abs(eltérés%) &lt; küszöb</c> soha nem teljesül, tehát minden tétel érvénytelen lesz.
/// Nem mérendőnél a <c>!IsMeasurable ||</c> rövidre zár, ezért ott irreleváns.</item>
/// </list>
/// Az <c>IsMeasurable</c> maga nem hiány-feltétel — hiányzó sor esetén a rendszer nem mérendőként
/// viselkedik (<c>GetValueOrDefault&lt;bool&gt;</c>), tehát ott a küszöb sem kell.
/// </summary>
public bool IsIncomplete =>
Tare is null
|| AverageWeight is null or <= 0
|| CratesPerPallet is null or <= 0
|| (IsMeasurable == true && AverageWeightTreshold is null or <= 0);
}
/// <summary>Egy rács-sor: termék-azonosítók + a törzsadat.</summary>
public sealed class ProductMasterDataRow
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public string Sku { get; init; } = string.Empty;
public ProductMasterDataValues Values { get; init; } = ProductMasterDataValues.Empty;
}
@@ -0,0 +1,212 @@
#nullable enable
using FruitBank.Common.Entities;
using LinqToDB;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Services.FileStorage;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
/// <summary>
/// Adattisztítás: a félbehagyott beérkezés-rögzítések <b>fizikai</b> törlése az admin gridjéről.
///
/// <para>
/// ⚠️ <b>Miért őrzött minden törlés:</b> a <c>FruitBankEventConsumer</c> ma <b>kaszkádol</b> —
/// <c>Shipping</c> törlése viszi a dokumentumait, a dokumentumé a tételeit, a tételé a raklapjait.
/// Egyetlen felügyelet nélküli törlés tehát az egész részfát kiirtaná. Az itteni feltételek pont azt
/// biztosítják, hogy a kaszkádnak <b>ne legyen mit</b> törölnie — vagyis a feltétel az egyetlen védelem.
/// Ezért értékeljük újra <b>a tranzakción belül</b> is: a gomb megnyomása és a törlés között beeshet
/// egy új dokumentum vagy tétel.
/// </para>
///
/// <para>
/// A meglévő <c>FruitBankDbContext.DeleteShipping*SafeAsync</c> metódusokat szándékosan NEM hívjuk:
/// azok feltétel nélküliek, és mindegyik <b>saját</b> tranzakciót nyit, amin kívülre esne az ellenőrzés.
/// </para>
///
/// <para>Későbbi kör: logikai törlés (Deleted flag) azoknak a tételeknek, amikhez van kapcsolt adat.</para>
/// </summary>
public sealed class ShippingCleanupService(FruitBankDbContext ctx, FileStorageService fileStorageService)
{
#region Shipping
/// <summary>Kamion törölhető, ha nincs hozzá rendelve dokumentum.</summary>
public async Task<DeleteCheckResult> CanDeleteShippingAsync(int shippingId)
{
if (shippingId <= 0) return DeleteCheckResult.Deny("Érvénytelen azonosító.");
var shipping = await ctx.Shippings.GetByIdAsync(shippingId, false);
if (shipping == null) return DeleteCheckResult.Deny("A kamion nem található.");
var documentCount = await ctx.ShippingDocuments.GetAll()
.CountAsync(sd => sd.ShippingId == shippingId);
return documentCount > 0
? DeleteCheckResult.Deny($"Nem törölhető: {documentCount} dokumentum tartozik hozzá.")
: DeleteCheckResult.Allow();
}
public async Task<DeleteCheckResult> DeleteShippingAsync(int shippingId)
{
DeleteCheckResult result = DeleteCheckResult.Deny("A törlés nem hajtódott végre.");
await ctx.TransactionSafeAsync(async _ =>
{
result = await CanDeleteShippingAsync(shippingId);
if (!result.CanDelete) return false;
await ctx.Shippings.DeleteAsync(shippingId, true);
result = DeleteCheckResult.Allow();
return true;
});
return result;
}
#endregion
#region ShippingDocument
/// <summary>
/// Dokumentum törölhető, ha nincs tétele ÉS nincs rá <b>befelé irányuló</b> EKÁER-bejelentés.
/// <para>
/// Az <c>EkaerHistoryMapping.ForeignKey</c> polimorf: kifelé a <c>Order.Id</c>-t jelenti, ezért a
/// szülő <c>EkaerHistory.IsOutgoing == false</c> szűrés nélkül egy azonos azonosítójú rendelés
/// hamisan blokkolna.
/// </para>
/// </summary>
public async Task<DeleteCheckResult> CanDeleteShippingDocumentAsync(int shippingDocumentId)
{
if (shippingDocumentId <= 0) return DeleteCheckResult.Deny("Érvénytelen azonosító.");
var document = await ctx.ShippingDocuments.GetByIdAsync(shippingDocumentId, false);
if (document == null) return DeleteCheckResult.Deny("A dokumentum nem található.");
var itemCount = await ctx.ShippingItems.GetAll()
.CountAsync(si => si.ShippingDocumentId == shippingDocumentId);
if (itemCount > 0)
return DeleteCheckResult.Deny($"Nem törölhető: {itemCount} tétel tartozik hozzá.");
// Teljes névvel: a paraméter nélküli CountAsync kétértelmű a nop
// System.Linq.AsyncIQueryableExtensions és a LinqToDB.AsyncExtensions között.
var ekaerCount = await LinqToDB.AsyncExtensions.CountAsync(
from mapping in ctx.EkaerHistoryMappings.GetAll()
join history in ctx.EkaerHistories.GetAll() on mapping.EkaerHistoryId equals history.Id
where mapping.ForeignKey == shippingDocumentId && !history.IsOutgoing
select mapping.Id);
return ekaerCount > 0
? DeleteCheckResult.Deny("Nem törölhető: EKÁER-bejelentés hivatkozik rá.")
: DeleteCheckResult.Allow();
}
/// <summary>
/// A dokumentum törlése a fájl-kötéseit is elviszi, a fájlt magát viszont csak akkor, ha nem maradt
/// rá másik kötés — azonos tartalmú feltöltésnél ugyanis a <c>FileStorageService</c> a <b>meglévő</b>
/// <c>Files</c> sort adja vissza (hash-alapú deduplikáció), tehát egy sor több dokumentumhoz tartozhat.
/// <para>
/// A fájl fizikai törlése tudatos: az újrafeldolgozás idempotencia-ellenőrzése a
/// <c>Files.FileHash</c>-re kulcsol, tehát a sor bennhagyása véglegesen blokkolná az újraimportot.
/// </para>
/// </summary>
public async Task<DeleteCheckResult> DeleteShippingDocumentAsync(int shippingDocumentId)
{
DeleteCheckResult result = DeleteCheckResult.Deny("A törlés nem hajtódott végre.");
await ctx.TransactionSafeAsync(async _ =>
{
result = await CanDeleteShippingDocumentAsync(shippingDocumentId);
if (!result.CanDelete) return false;
var links = await ctx.ShippingDocumentToFiles.GetAll()
.Where(link => link.ShippingDocumentId == shippingDocumentId)
.ToListAsync();
var fileIds = links.Select(link => link.FilesId).Distinct().ToList();
foreach (var link in links)
await ctx.ShippingDocumentToFiles.DeleteAsync(link, false);
foreach (var fileId in fileIds)
{
// A kötések már törölve — ha maradt találat, azt MÁSIK dokumentum tartja.
var stillReferenced = await ctx.ShippingDocumentToFiles.GetAll()
.AnyAsync(link => link.FilesId == fileId);
if (stillReferenced) continue;
var file = await ctx.Files.GetByIdAsync(fileId);
if (file != null) await fileStorageService.DeleteFileWithBlobAsync(file);
}
await ctx.ShippingDocuments.DeleteAsync(shippingDocumentId, true);
result = DeleteCheckResult.Allow();
return true;
});
return result;
}
#endregion
#region ShippingItemPallet
/// <summary>
/// Raklap törölhető, ha nem hordoz mérési adatot: <c>!IsMeasured</c>, és a mennyiség és a súly is 0.
/// <para>
/// A <c>TrayQuantity == 0</c> nem formalitás. Nem mérendő terméknél egy <b>érvényes, befejezett</b>
/// mérés súlya definíció szerint 0 (<c>MeasuringItemPalletBase.IsValidMeasuringValues</c>), tehát
/// pusztán a súlyra nézve egy jó mérés is törölhető lenne. A nullázás ezért tudatos admin-művelet:
/// egyben azt is biztosítja, hogy ne keveredjen össze, melyik sort akarta törölni.
/// </para>
/// </summary>
public async Task<DeleteCheckResult> CanDeleteShippingItemPalletAsync(int palletId)
{
if (palletId <= 0) return DeleteCheckResult.Deny("Érvénytelen azonosító.");
var pallet = await ctx.ShippingItemPallets.GetByIdAsync(palletId, false);
if (pallet == null) return DeleteCheckResult.Deny("A raklap nem található.");
if (pallet.IsMeasured)
return DeleteCheckResult.Deny("Nem törölhető: a raklap le van mérve.");
if (pallet.TrayQuantity != 0 || pallet.GrossWeight != 0)
return DeleteCheckResult.Deny("Nem törölhető: előbb nullázd a rekeszszámot és a bruttó súlyt.");
return DeleteCheckResult.Allow();
}
/// <summary>
/// A raklap törlése után a szülő tétel mért értékei <b>maguktól</b> frissülnek: az
/// <c>EntityDeletedEvent&lt;ShippingItemPallet&gt;</c> kezelője újraszámolja őket — ezért kell
/// itt <c>publishEvent: true</c>.
/// </summary>
public async Task<DeleteCheckResult> DeleteShippingItemPalletAsync(int palletId)
{
DeleteCheckResult result = DeleteCheckResult.Deny("A törlés nem hajtódott végre.");
await ctx.TransactionSafeAsync(async _ =>
{
result = await CanDeleteShippingItemPalletAsync(palletId);
if (!result.CanDelete) return false;
await ctx.ShippingItemPallets.DeleteAsync(palletId, true);
result = DeleteCheckResult.Allow();
return true;
});
return result;
}
#endregion
}
/// <summary>
/// Törölhetőség eredménye. Szándékosan <b>okot</b> hordoz, nem csak bool-t: a grid fő értéke, hogy
/// megmondja, MIÉRT nem törölhető egy sor — egy letiltott gomb önmagában nem segít az adminon.
/// </summary>
public sealed record DeleteCheckResult(bool CanDelete, string? Reason)
{
public static DeleteCheckResult Allow() => new(true, null);
public static DeleteCheckResult Deny(string reason) => new(false, reason);
}
@@ -0,0 +1,55 @@
@*
„Kamu e-mail" gomb az admin vásárló-létrehozó űrlapján.
A gombot JS fűzi az #Email input mellé: a nop `_CreateOrUpdate.Info.cshtml`-jében ott nincs
widget-zóna, a `CustomerDetailsButtons` viszont a fejlécben renderelődik. Így nem kell
nop-nézetet másolni (ACCORE 0011).
*@
<script>
$(function () {
var $email = $('#Email');
if ($email.length === 0) return;
/* Ha az e-mail mező nem input-group-ban ül, becsomagoljuk — így a gomb pontosan mellé kerül. */
var $group = $email.parent('.input-group');
if ($group.length === 0) {
$email.wrap('<div class="input-group"></div>');
$group = $email.parent('.input-group');
}
$group.append(
'<div class="input-group-append">' +
'<button type="button" id="fb-fake-email-btn" class="btn btn-secondary" ' +
'title="Generált, nem létező cím annak a vevőnek, aki nem ad meg e-mailt">' +
'<i class="fas fa-user-secret"></i> Kamu e-mail' +
'</button>' +
'</div>');
$('#fb-fake-email-btn').on('click', function () {
var $btn = $(this);
var original = $btn.html();
$btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>');
$.ajax({
url : '@Url.Content("~/Admin/CustomerAttributes/SuggestPlaceholderEmail")',
type: 'POST',
data: { __RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val() }
}).done(function (response) {
if (!response || response.success === false) {
alert((response && response.error) || 'A cím generálása nem sikerült.');
return;
}
$email.val(response.email).trigger('change');
}).fail(function () {
alert('A cím generálása nem sikerült (hálózati hiba).');
}).always(function () {
$btn.prop('disabled', false).html(original);
});
});
});
</script>
+1
View File
@@ -13,6 +13,7 @@ Topic documentation for the FruitBank-specific NopCommerce plugin (Layer 3 — c
## Topic folders
- [`adr/`](adr/README.md) — Architecture Decision Records: durable records of architecture-level decisions (Nygard style), with an index
- [`SIGNALR/`](SIGNALR/README.md) — SignalR endpoints exposed by this plugin (project-specific variant)
- [`PREORDER/`](PREORDER/README.md) — Customer pre-order workflow: advance orders converted to real orders on incoming stock (+ `PREORDER_ISSUES.md` / `PREORDER_TODO.md`)
- [`ORDERDRAFT/`](ORDERDRAFT/README.md) — AI-assisted intake: free-text messages parsed into order drafts, admin-approved into pre-orders (+ `ORDERDRAFT_ISSUES.md`)
@@ -0,0 +1,261 @@
# ADR 0001-V4T7: A termék mérési/logisztikai törzsadata elsőrendű, plugin-tulajdonú táblába kerül a `GenericAttribute`-ok helyett
## Status
Proposed (2026-08-11) — **interjú nélkül készült** (Adam kérése), a [ADR 0003](../../../../../../FruitBankHybridApp/docs/adr/0003-shipping-item-to-shipping-mapping.md)
mintájára. A `Decision` szakasz **javaslat**: a döntés Adamé, ratifikálásig `Proposed`. A kiváltó igény (tömeges
törzsadat-szerkesztő admin oldal) **nem blokkolt** — a `Decision` 6. pontja külön kimondja, hogyan épülhet meg
a felület azelőtt, hogy a tárolás átállna.
## Context
- **Kiváltó igény (Adam, 2026-08-10):** kell egy admin oldal, ami a termékek **általunk bevezetett** mérési/logisztikai
törzsadatát szerkeszti tömegesen (100-anként lapozva, sor-szintű mentéssel, név/cikkszám kereséssel és
„csak hiányos törzsadatú" szűrővel). **Egy dedikált szerkesztő megépítése maga is döntés**: kimondja, hogy a mai
tárolás marad — ezért kell előbb ez az ADR.
- **Az érintett öt érték** (a `Product` `GenericAttribute`-jai, `KeyGroup = "Product"`):
| Kulcs | Típus | Jelentés | Írója |
|---|---|---|---|
| `IsMeasurable` | bool | mérendő-e a termék (mester-kapcsoló) | **csak ember** |
| `Tare` | double | egy rekesz tárasúlya | **csak ember** |
| `AverageWeightTreshold` | double | megengedett eltérés %-ban | **csak ember** |
| `CratesPerPallet` | int | rekesz/raklap arány | **csak ember** |
| `AverageWeight` | double | várt átlagsúly rekeszenként | **ember ÉS rendszer** — lásd a következő pontot |
- **Az `AverageWeight` kettős tulajdonú, és ez a legfontosabb finomság az egész felületen.**
A `MeasurementService.CalculateAndSetAverageWeight` minden beérkezési raklap-mentés után
([`FruitBankDataController.cs:841`](../../Controllers/FruitBankDataController.cs)) **visszatanulja** az átlagsúlyt
a tétel mért raklapjaiból — de **csak mérendő terméknél**. A metódus első lépése egy korai kilépés
(`if (!shippingItemPallet.ShippingItem.IsMeasurable) return;`), és a kód kommentje kimondja az üzleti szabályt:
a **nem mérendő** termék `AverageWeight`-je fix, kézzel megadott érték, amiből az **EKÁER bruttó tömeg**
származik, és amit a mérésből **soha nem szabad felülírni**.
Következmény a szerkesztőre: ugyanaz a mező **két különböző dolog** a két terméktípusnál —
nem mérendőnél tartós törzsadat (és EKÁER-bemenet), mérendőnél viszont **kiinduló érték**, amit a következő
bemérés felülír. A felületnek ezt jeleznie kell, különben az admin azt hiszi, tartósan állított be valamit.
- **A többi négy értéknek nincs konkurens rendszer-írója** — egyedüli írási útjuk a lenti form-kaparás. Ezért
szerkeszthetők tömegesen kockázat nélkül.
- **Két szomszédos érték ugyanezen a `KeyGroup`-on kimarad**, mert teljesen vezéreltek: a `NetWeight`-et a
készlet-/mérési lánc írja (`FruitBankDbContext.UpdateStockQuantityAndWeightAsync`,
`InsertOrUpdateNetWeightAsync`), az `IncomingQuantity`-t a beérkezés és a preorder-konverzió
(`PreorderConversionService.SyncIncomingQuantityAsync`, `FruitBankDbContext`, `FileManagerController`).
Kézi szerkesztésük a két írást egymás ellen fordítaná (döntés: Adam, 2026-08-10).
- **A mai írási út egyetlen, és törékeny:** `FruitBankEventConsumer.SaveProductCustomAttributesAsync` az
`EntityUpdatedEvent<Product>` kezelése közben a **`HttpContext.Request.Form`-ból kaparja ki** az értékeket
([`FruitBankEventConsumer.cs:182`](../../Domains/EventConsumers/FruitBankEventConsumer.cs)). Következményei:
- **Csendes no-op**, ha a form nem tartalmazza a kulcsokat (`return (false, null)`) — bármely programozott
`UpdateProductAsync` (import, API, háttérjob) **nyom nélkül** nem menti a törzsadatot.
- A **validáció bent van, de a hibája elvész**: a negatív `Tare` / `AverageWeight` / `CratesPerPallet` `Exception`-t
dob, amit a metódus `catch`-e **naplóba nyel** — a termék eközben rendben elmentődik, a felhasználó nem kap hibát.
- A tervezett tömeges szerkesztő **ezt az utat nem tudja használni** (nincs nop termék-form), tehát közvetlenül
a `FruitBankAttributeService.InsertOrUpdateGenericAttributeAsync`-et hívná — ezzel **második írási út** keletkezik,
a mostanitól **eltérő validációval**.
- **Az értékek teherhordók — nem kényelmi beállítások.** Fogyasztóik regulációs, pénzügyi és kapacitás-számítások:
- `CratesPerPallet` → [ADR 0004-M9F3](../../../../../../FruitBankHybridApp/docs/adr/0004-M9F3-fractional-pallets-and-remainder-grouping.md):
`ShippingItem.Pallets = QuantityOnDocument / CratesPerPallet` → kamion-foglaltság → EKÁER.
- `Tare` + `AverageWeight` → a nem mérendő tétel derivált súlya ([ADR 0001](../../../../../../FruitBankHybridApp/docs/adr/0001-non-measurable-item-derived-weight.md)),
és a tervezési súly-fallback (`raklap×10kg + rekesz×(tára+átlagsúly)`, [ADR 0003](../../../../../../FruitBankHybridApp/docs/adr/0003-shipping-item-to-shipping-mapping.md) 7. pont)
→ a 22 500 kg-os kettős korlát → EKÁER deklarált tömeg.
- `IsMeasurable` → átmásolódik a `ShippingItem`-re, és a `CustomPriceCalculationService`-ben **az árazási ágat
választja** (súly- vs. mennyiség-alapú végár) → **számlázott összeg**.
- `AverageWeightTreshold``OrderItemDto.AverageWeightIsValid` eltérés-kapu.
- **A kulcs-érték tárolás konkrét, ma is fennálló hátrányai** (nem elvi kifogások):
1. **A „nincs beállítva" és a „nulla" egybeesik.** A `GetValueOrDefault<TValue>` a hiányzó sorra `default`-ot ad
([`GenericAttributeExtensions.cs:21`](../../../../Libraries/Mango.Nop.Core/Extensions/GenericAttributeExtensions.cs)).
Az ADR 0004-M9F3 emiatt **kénytelen volt külön ágat írni** a `CratesPerPallet == 0` esetre. Egy `NULL`-ozható
tipizált oszlop ezt szerkezetileg megszünteti.
**Nem elméleti probléma — élő defekt volt, 2026-08-11-én javítva (Adam):** a `CalculateAndSetAverageWeight`
érvénytelen raklapnál (`IsValidMeasuringValues == false`) **feltétel nélkül kiírta a 0-t** a termék tanult
átlagsúlyába. A hatás nem volt néma: az `OrderItemDto.AverageWeightIsValid` `AverageWeight > 0`-t követel,
tehát az érintett termék **minden** mérendő tétele érvénytelenre fordult, amíg valaki vissza nem írta az
értéket. A javítás az `else` ágban **korai kilépés** — érvénytelen raklapból nincs mit tanulni, a törzsadat
érintetlen marad. Figyelemre méltó, hogy a kézenfekvőbb javítás (a jelenlegi érték visszaírása)
**pont a 0-vs-hiány összemosásán bukott volna el**: annak a terméknek, amelynek még nincs sora,
`GetValueOrDefault` miatt 0-t olvasott volna, és ezzel **beszúrt volna egy 0-s sort** — a „nincs beállítva"
állapotot átfordítva „kifejezetten 0"-ba. A hiba tehát javítva, de a mögötte lévő **tárolási** ok nem.
2. **Nincs típusbiztonság a tárolás szintjén.** Az érték `nvarchar`; a `CommonHelper.To<T>` elrontott értékből
némán `default`-ot csinál. Se `NOT NULL`, se `CHECK (>= 0)` — a validáció csak alkalmazás-oldalon él, és
(lásd fent) a hibája elveszik.
3. **Latens több-boltos defekt.** Az **írás** a `storeContext.GetCurrentStore().Id`-ra szűkít
([`FruitBankAttributeService.cs:142`](../../Services/FruitBankAttributeService.cs)), az **olvasás** viszont
StoreId-t **egyáltalán nem szűr**, és `SingleOrDefault`-ot használ. Egyetlen bolt mellett ez ma nem üt ki;
két boltnál a kulcsonként két sor az olvasáskor **`InvalidOperationException`-t dobna**.
4. **A kért „hiányos törzsadatú" szűrő maga is drága.** Kulcs-érték soroknál termékenként öt sor pivotálása
vagy öt `LEFT JOIN` kell ahhoz, amit tipizált oszlopokon egy `WHERE ... IS NULL` elintéz. A szűrő
**a lapozással együtt** (100-anként, szerver-oldalon) a KV-modellen a legdrágább lekérdezés a felületen.
- **Miért most kell dönteni:** (a) a szerkesztő megépítése ratifikálja a tárolást; (b) az ADR 0004-M9F3
**nyolc napja** ütközött az 1. pontba, tehát a modell már **korrektségi** árat kér, nem csak eleganciát;
(c) az EKÁER bírság-kockázatot hordoz, tehát az „elrontott/hiányzó törzsadat némán rossz számot ad" mintázat
itt a legdrágább.
- **Precedens a házban:** a `Product.Gtin` oszlop **átmenetileg az EKÁER VTSZ-t hordozza** — dokumentáltan hibás,
külön mezőbe választandó (`ProductDto.cs:33`). Ez a **meglévő nop-oszlop újraértelmezésének** iskolapéldája,
és az alábbi alternatívák közül a „D" pontosan ezt ismételné meg. Szerkezetileg rokon nyitott bejegyzés:
`FBANKAPP-DMODEL-I-K3D9` (a partner-entitások nop-referenciái szabad string-ek FK helyett) — ugyanaz az osztály:
**tipizálatlan tárolás hivatkozás-integritás nélkül**.
- **Scope IN:** az öt fenti érték tárolási módja; az írási utak egységesítése; a „nincs beállítva" ábrázolása;
a migráció és a visszatöltés menete.
**Scope OUT:** a szerkesztő felület konkrét kialakítása (rács, lapozás, mentés-UX — a minta
[`FruitBankOrderList.cshtml`](../../Areas/Admin/Views/Order/FruitBankOrderList.cshtml)); a **vezérelt** értékek
(`NetWeight`, `IncomingQuantity`) tárolása és szerkeszthetősége; a `Gtin`/VTSZ szétválasztása; a `Product`
egyéb `GenericAttribute`-jai.
## Decision
**Az öt érték elsőrendű, tipizált oszloppá válik egy új, plugin-tulajdonú `ProductMasterData` táblában (1:1 a
`Product`-tal), nem pedig a nopCommerce `Product` táblájának bővítésével; a `GenericAttribute`-os tárolás
kivezetésre kerül. A szerkesztő felület viszont már a mai tárolásra ráépülhet, egy szolgáltatás-határ mögött,
hogy a UI a váltáskor ne íródjon újra.**
1. **Új entitás `ProductMasterData`** (tábla: `fbProductMasterData`), a repo `fb`-prefixes, plugin-tulajdonú
tábla-konvenciója szerint:
- `Id`, `ProductId` (FK → `Product`, **egyedi** — 1:1),
- `IsMeasurable` (`bit NULL`), `Tare` (`float NULL`), `AverageWeight` (`float NULL`),
`AverageWeightTreshold` (`float NULL`), `CratesPerPallet` (`int NULL`),
- `Created` / `Modified`.
- **Minden oszlop `NULL`-ozható**, és a `NULL` **jelentése: „nincs beállítva"** — ez az 1. hátrány (0 vs. hiány)
szerkezeti megszüntetése. A „hiányos törzsadatú" szűrő ezzel `WHERE IsMeasurable IS NULL OR Tare IS NULL OR …`.
- **`CHECK` megszorítások** a nem-negatív értékekre (`Tare`, `AverageWeight`, `AverageWeightTreshold`,
`CratesPerPallet`) — az a validáció, ami ma a `catch`-ben elvész, kerüljön az adatbázisba is.
2. **NEM a nopCommerce `Product` táblájába** kerülnek az oszlopok. Indok: a `Product` keret-tulajdonú tábla; a
bővítése minden nop-verzióváltásnál újra-ellenőrzést kér (`ACCORE 0009` — közös réteg több nop-verzió felett;
`ACCORE 0011` — másolt és override-olt nop-kód). A plugin-tulajdonú tábla ugyanezt a tipizáltságot adja
**verzió-kockázat nélkül**.
3. **Egyetlen írási út:** új `ProductMasterDataService` (a plugin `Services/` mappájában), amelyen **minden** írás
átmegy — a nop termék-szerkesztő, a tömeges szerkesztő, a `MeasurementService` átlagsúly-visszatanulása, és
bármely jövőbeli import. A validáció **itt** él, és **hibát ad vissza**, nem naplóba nyeli.
A `FruitBankEventConsumer.SaveProductCustomAttributesAsync` form-kaparása **kivezetendő**: a nop termék-oldal
a `ProductAttributes` view-componentből úgyis saját mezőket posztol, azok a jövőben a szolgáltatáson át mentődnek.
⚠️ „Egyetlen **út**", nem „egyetlen **hívó**": a `MeasurementService.SetProductAverageWeight` **jogos** író
(7. pont), csak ugyanazon a validált határon kell átmennie, mint a kézi szerkesztésnek.
4. **`ProductDto` olvasói kompatibilisek maradnak:** az öt property megmarad `[NotColumn, NotMapped]`
számítottként, de a forrásuk a `GenericAttributes` helyett a betöltött `ProductMasterData` asszociáció lesz.
⚠️ A `ProductDto` **`[AcBinarySerializable]` wire-kontraktus** — a változás **plugin + Hybrid együtt-deployt**
kíván, ugyanúgy, ahogy az ADR 0003 7b. pontja rögzíti a hármas újrafordítást.
5. **Migráció:** a tábla létrehozása + **visszatöltés** a meglévő `GenericAttribute` sorokból, majd egy
**kettős-olvasású átmenet** (először a tábla, hiány esetén a `GenericAttribute` — így a visszatöltés
ellenőrizhető), végül a `GenericAttribute` sorok törlése. A DB-változást **az üzemeltető kézi SQL-lel** vezeti be,
a repo bevált menete szerint (ADR 0003 9. pont, ADR 0004-M9F3) — a pluginban létező FluentMigrator
(`Migrations/SchemaMigration.cs`) alternatíva, de eltérne a szomszédos `fb…` táblák bevezetési gyakorlatától;
a választás az implementáció első lépése.
6. **A szerkesztő felület nem várja meg a migrációt.** A tömeges szerkesztő a 3. pont szerinti
`ProductMasterDataService` **ellen** épül; a szolgáltatás **első változata még `GenericAttribute`-ra ír**
(a mai `InsertOrUpdateGenericAttributeAsync`-en át), és a tároló-váltás **csak a szolgáltatás belsejét** cseréli.
Így a felület (rács, lapozás, keresés, sor-mentés) egyszer készül el. **Kivétel:** a „hiányos törzsadatú" szűrő
a KV-fázisban szükségképpen drágább lekérdezés — ez a szűrő a migráció után egyszerűsödik.
7. **Az `AverageWeight` kettős tulajdona a tárolásban is látszik.** Az oszlop `NULL`-ja itt azt jelenti:
„sem ember nem adta meg, sem mérés nem tanulta meg". A `MeasurementService` visszatanulása **változatlanul
marad** (mérendő terméknél tanul, nem mérendőnél a korai kilépés véd) — csak az írás megy át a 3. pont szerinti
szolgáltatáson. **A szerkesztő pedig jelzi**, hogy mérendő terméknél a kézzel beírt érték a következő bemérésig
él; nem mérendőnél viszont tartós, és **EKÁER-bemenet**. A mező **nem** válik írásvédetté mérendő terméknél:
a tanult érték elromolhat (lásd a `Context` 1. pontjában a 0-ra írást), és ilyenkor kézzel kell helyreállítani.
8. **A `NULL` visszamenőleg is jelentést kap:** a visszatöltés a **hiányzó** `GenericAttribute` sort `NULL`-ra
képezi, a **létező 0** értéket pedig 0-ra. Ezzel az ADR 0004-M9F3 `CratesPerPallet == 0` külön ága
`IS NULL`-vizsgálatra egyszerűsödik — de a viselkedése **nem változik**, tehát ez nem törő lépés.
9. **A „hiányos törzsadat" definíciója (Adam, 2026-08-11)** — a szűrő **sor-létezésre** épül, nem `== 0`-ra.
Fontos: a „nincs sor" és a „0 értékű sor" **a mai KV-tárolásban is megkülönböztethető** (`GetValueOrNull`,
illetve SQL `NOT EXISTS`); csak a `ProductDto` propertyk mossák össze, mert `GetValueOrDefault`-ot használnak.
Ezért a szűrő már a KV-fázisban is helyes szemantikát ad, és a migráció után **változatlan logikával**
`IS NULL`-ra képződik le:
| Mező | Hiányos, ha | Miért |
|---|---|---|
| `Tare` | **nincs sor** | a 0 legitim (`MeasuringItemPalletBase.IsValidMeasuringValues`: `TareWeight >= 0`) |
| `AverageWeight` | nincs sor, **vagy** ≤ 0 | az `AverageWeightIsValid` `> 0`-t követel |
| `CratesPerPallet` | nincs sor, **vagy** ≤ 0 | 0-val nem származtatható raklapszám (ADR 0004-M9F3) |
| `AverageWeightTreshold` | **csak ha `IsMeasurable`**: nincs sor, vagy ≤ 0 | lásd a következő bekezdést |
Az `AverageWeightTreshold` hiánya **nem kozmetikai**: az `AverageWeightIsValid` a
`Math.Abs(eltérés%) < AverageWeightTreshold` összehasonlításon áll, és **hiányzó küszöbnél a jobb oldal 0**,
amit semmilyen abszolút érték nem tud alulmúlni → a termék **minden** mérendő tétele tartósan érvénytelen,
és vele az `OrderDto.IsAllOrderItemAvgWeightValid` is. Ugyanakkor a `!IsMeasurable ||` rövidre zár, tehát
**nem mérendő** terméknél a küszöb irreleváns — ott megjelölni fals pozitív volna a munkalistán.
Az `IsMeasurable` maga **nem** hiány-feltétel, csak a küszöb feltétele.
## Consequences
**Positive:**
- A „nincs beállítva" és a „0" **szerkezetileg** elválik — az ADR 0004-M9F3 kényszerű külön ága
(és minden jövőbeli hasonló) elesik.
- A validáció (nem-negatív értékek) **az adatbázisban is** ott van, nem csak egy `catch`-be nyelt `Exception`-ben.
- A **„hiányos törzsadatú" szűrő** — a felület egyik fő értéke — triviális `IS NULL` lekérdezéssé válik,
szerver-oldali lapozással jól viselkedik.
- **Egyetlen írási út** — megszűnik a „csak akkor mentődik, ha épp nop-form POST van" csendes viselkedés, ami ma
minden programozott termék-frissítést érint.
- A latens **több-boltos** defekt (kulcsonként több sor → `SingleOrDefault` robbanás) megszűnik.
- **Nop-verzió-független:** a plugin saját tábláját a nop frissítése nem érinti.
**Negative:**
- **Wire-kontraktus változás:** a `ProductDto` átalakítása **plugin + Hybrid együtt-deployt** kíván; részleges
telepítés törik. (A házban bevett, de valós üzemeltetési kötöttség.)
- **Migráció + visszatöltés + kettős-olvasású átmenet** — egyszeri, de nem elhanyagolható munka, és a visszatöltés
helyessége éles adaton ellenőrzendő.
- **Kézi SQL** az üzemeltetőnél: emberi lépés, elmaradása esetén a kettős olvasás miatt nem törik azonnal,
hanem **némán a régi értékeket** adja — ezt a migrációs ellenőrzésnek ki kell mutatnia.
- **Az `AverageWeight` kettős tulajdonát a tárolás-váltás NEM szünteti meg** — a mező mérendő terméknél továbbra is
„kiinduló érték, amit a mérés felülír", nem mérendőnél tartós EKÁER-bemenet. Ez a szerkesztőben terméktípus
szerint eltérő jelzést kíván; a `NULL` csak annyit javít, hogy a „soha nem volt értéke" megkülönböztethető lesz.
- Egy **join** kerül a termék-olvasásokba ott, ahol ma a `GenericAttributes` asszociáció úgyis betöltődik —
önmagában nem többlet, de a lekérdezések átnézését igényli.
- A 6. pont miatt a szerkesztő **kétszer** érinti a tároló-réteget (először KV-re, majd táblára) — a UI nem íródik
újra, de a szolgáltatás belseje igen.
**Follow-ups required:**
- **Topik-kód regisztrálása:** a plugin `.github/TOPIC_CODES.md`-jében ma nincs adatmodell/törzsadat topik
(`PREO` / `EKAER` / `ORDD` van). Ehhez az ADR-hez **horgony-bejegyzés kell** egy új topik alatt
(javaslat: `PMDATA` — termék-törzsadat), a `docs-check` registry-maintenance menete szerint. **Adam jóváhagyásával.**
- **A szerkesztő felület megépítése** a 6. pont szerint (minta: `FruitBankOrderList.cshtml` — DataTables,
szerver-oldali lapozás, AJAX, sor-renderelők). Az admin menübe magától bekerül (a `SystemName` a menü-fehérlistán van).
- **A `SaveProductCustomAttributesAsync` form-kaparásának kivezetése** a `ProductAttributes` view-component
mentési útjával együtt — a `NetWeight` és `IncomingQuantity` ágakat is beleértve, amelyek **maradnak**
`GenericAttribute`-on (ezek nem tárgyai ennek az ADR-nek, de ugyanabban a metódusban laknak).
- ✅ **Az átlagsúly 0-ra írása javítva (2026-08-11, Adam)** — korai kilépés az `else` ágban, plusz a fölösleges
`ProductDtos.UpdateAsync` kikommentelve. A `//TODO ELSE` (a súly dokumentumból való származtatása érvénytelen
mérés esetén) **továbbra is nyitva** — az a mérési lánc kérdése, nem ezé az ADR-é.
- **A [`MEASUREMENT.md`](../MEASUREMENT.md) „Product `AverageWeight` updated from measured data" sora HELYES**
(a `MeasurementService` visszatanulása) — viszont **pontosítandó** azzal, hogy ez **csak mérendő termékre** áll,
és hogy a nem mérendő termék kézzel megadott átlagsúlyát a mérés soha nem írja felül.
- **Az öt kulcs `SCHEMA.md` / `DOMAIN_MODEL.md` táblázatai** frissítendők a migráció után; a `DOMAIN_MODEL.md`
„Product attributes" táblája ma a `CratesPerPallet`-et **nem is sorolja fel**.
## Alternatives considered
- **Marad `GenericAttribute`, csak megépül a szerkesztő** (rejected): nulla migrációs költség, és a felület
önmagában valós értéket ad — de konzerválja mind az öt fenti hátrányt épp akkor, amikor az EKÁER, az árazás és a
kamion-kapacitás már ráépült, és amikor az ADR 0004-M9F3 nyolc napja fizetett érte. A „hiányos törzsadatú" szűrő —
a felület fő értéke — ezen a modellen a legdrágább.
- *Reversibility:* jó (bármikor elővehető) — **ez az egyetlen alternatíva, amit az elhalasztás nem büntet**,
ezért szerepel a `Decision` 6. pontjában átmeneti állapotként.
- **Oszlopok a nopCommerce `Product` táblájára** (rejected): ugyanazt a tipizáltságot adja, de keret-tulajdonú
táblát módosít, ezért minden nop-verzióváltásnál újra-ellenőrzést kér (`ACCORE 0009`, `ACCORE 0011`), és a nop
`Product` entitás bővítését is igényli.
- *Future flexibility:* rossz — a nop-frissítés minden alkalommal döntési pontot csinál belőle.
- **Meglévő nop `Product`-mezők újraértelmezése** (pl. `Weight`, `AdditionalShippingCharge`) (rejected): nem kell
séma-változás, de **pontosan a `Gtin`↔VTSZ hibát ismételné meg**, amit a kódbázis már dokumentáltan bán
(`ProductDto.cs:33`). Egy mező két jelentést cipelne — ugyanaz a mintázat, amit az ADR 0003 (súly) és az
ADR 0004-M9F3 (raklapszám) is szétválasztott.
- **Öt külön, tipizált oszlop külön táblák helyett — azaz `ProductMasterData` helyett per-érték tábla** (rejected):
fölösleges normalizálás; az öt érték együtt keletkezik, együtt szerkesztődik és együtt olvasódik, tehát egy
1:1 tábla a természetes granularitás.
- **`GenericAttribute` marad, de tipizált nézet/validáló réteg épül fölé** (considered, elvetve): a 2. és 4.
hátrányt enyhítené alkalmazás-oldalon, de a tárolás továbbra sem tudná megkülönböztetni a hiányt a nullától
(1. hátrány), és `CHECK`-et sem adna — a legdrágább hátrányokat pont nem oldja meg.
## Related
- Related ADRs (kliens-oldali, `FruitBankHybridApp` — a `FruitBank.Common` ennek a pluginnak a függősége):
[0001 nem mérendő tétel derivált súlya](../../../../../../FruitBankHybridApp/docs/adr/0001-non-measurable-item-derived-weight.md) ·
[0003 tétel↔kamion allocation](../../../../../../FruitBankHybridApp/docs/adr/0003-shipping-item-to-shipping-mapping.md) ·
[0004-M9F3 tört raklapszám](../../../../../../FruitBankHybridApp/docs/adr/0004-M9F3-fractional-pallets-and-remainder-grouping.md)
(ez utóbbi a `CratesPerPallet` első valódi fogyasztója, és a „0 vs. hiány" probléma bizonyítéka).
- Keret-oldali ADR-ek: `ACCORE 0009` (közös réteg több nop-verzió felett), `ACCORE 0011` (másolt és override-olt
nopCommerce-kód) — mindkettő a 2. döntési pontot támasztja alá.
- Related TODOs/Issues: `FBANKAPP-DMODEL-I-K3D9` (partner-entitások nop-referenciái szabad string-ek FK helyett —
**szerkezetileg rokon**: tipizálatlan tárolás integritás nélkül).
- Kód-horgonyok: [`FruitBankAttributeService.cs`](../../Services/FruitBankAttributeService.cs) ·
[`FruitBankEventConsumer.SaveProductCustomAttributesAsync`](../../Domains/EventConsumers/FruitBankEventConsumer.cs) ·
[`GenericAttributeExtensions.cs`](../../../../Libraries/Mango.Nop.Core/Extensions/GenericAttributeExtensions.cs) ·
`FruitBank.Common/Dtos/ProductDto.cs`
- Tracking entry: **még nincs** — lásd a `Follow-ups` első pontját (topik-kód regisztrálása szükséges).
@@ -0,0 +1,39 @@
# Architecture Decision Records (ADR)
Architektúra-szintű döntések durable rekordjai (Nygard-stílus): egy fájl = egy döntés, `NNNN-<RAND>-<slug>.md` néven. Minden ADR: kontextus → döntés → következmények → alternatívák. A `Status` a Nygard 4-értékű szótár: `Proposed` / `Accepted` / `Superseded by ADR-XXXX` / `Rejected` (ez KÜLÖN a `_ISSUES.md`/`_TODO.md` 3-értékű `Open`/`InProgress`/`Closed` szótárától).
> Formátum + folyamat: `adr-author` skill (AyCode.Core `.github/skills/adr-author/`), sablon: `references/ADR_TEMPLATE.md`.
## Számozás és hivatkozás
Azonos a `FruitBankHybridApp/docs/adr/` sémájával — a számozás viszont **repónként külön** fut.
- **`<RAND>`**: 4 karakter `[A-Z0-9]`, generálva és ütközés-ellenőrizve az `AyCode.Core/.github/skills/docs-check/references/TOPIC_CODES.md` eljárása szerint.
- **`NNNN`**: `max(existing) + 1` írás előtt — a **monotonitásért**, nem az egyediségért; az ütközés ellen a suffix véd. **Az azonos `NNNN` érvényes**: azt jelenti, hogy a két rekord párhuzamosan született.
- **Hivatkozás**: a szabály a **suffixre** kulcsol, nem számra — ahol van suffix, mindig a **teljes ID**-t kell írni.
- **Repók közti hivatkozás**: a rekordokat a repó nevével kell megkülönböztetni (pl. `FBANKAPP ADR 0003`, `ACCORE 0011`), mert a `0001` mindkét repóban létezik.
## Index
Az implementáció állapotát ADR-enként **pontosan egy** `_TODO` / `_ISSUES` horgony-bejegyzés hordozza — ezért külön oszlop.
| ADR | Cím | Status | Tracking entry |
|---|---|---|---|
| [0001-V4T7](0001-V4T7-product-master-data-storage.md) | A termék mérési/logisztikai törzsadata elsőrendű, plugin-tulajdonú táblába kerül a `GenericAttribute`-ok helyett | Proposed (2026-08-11) | — (topik-kód regisztrálása szükséges, lásd az ADR `Follow-ups` szakaszát) |
## Ezt a repót érintő döntések más rétegben
A `FruitBank.Common` (a `FruitBankHybridApp` repóban) **ennek a pluginnak a függősége**, ezért az ott hozott
adatmodell-döntések ide is hatnak — a `ProductDto` és a mérési entitások onnan jönnek. Az alábbiak a jelen repó
kódjára közvetlenül kiható rekordok.
> ⚠️ A `0003` és a `0004-M9F3` a `FruitBankHybridApp` **`FruitBank_v0.0.8.1`+ ágain** él (a szállítmánytervezőé);
> a `v0.0.8.0`-s munkapéldányon a link nem oldódik fel. Merge után rendeződik.
| ADR | Cím | Status |
|---|---|---|
| [FBANKAPP 0001](../../../../../../FruitBankHybridApp/docs/adr/0001-non-measurable-item-derived-weight.md) | Nem mérendő tétel súlya — derivált pallet-mérés a termék átlagsúlyából és tárájából | Proposed (2026-07-14) |
| [FBANKAPP 0003](../../../../../../FruitBankHybridApp/docs/adr/0003-shipping-item-to-shipping-mapping.md) | Tétel↔kamion hozzárendelés külön mapping-táblával (`ShippingItemToShipping`) | Accepted (2026-07-23, rev. 2026-07-24 / 07-27) |
| [FBANKAPP 0004-M9F3](../../../../../../FruitBankHybridApp/docs/adr/0004-M9F3-fractional-pallets-and-remainder-grouping.md) | Tört raklapszám a rekesz/raklap arányból, és a maradékok összevonása közös raklapra | Accepted (2026-08-02) |
> Ide **csak más rétegben lakó** rekord kerülhet. A repó saját ADR-jei a fenti [Index](#index)-ben vannak.