Compare commits
7
Commits
7c09c0837c
...
d37763ab8a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d37763ab8a | ||
|
|
e70484d8c3 | ||
|
|
dc0e117483 | ||
|
|
0d2cde4013 | ||
|
|
5a7de3a414 | ||
|
|
ced5e6a738 | ||
|
|
1677087498 |
@@ -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"> </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"> </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<T></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 >= 0</c>).</item>
|
||||
/// <item><c>AverageWeight</c> — hiány vagy ≤ 0; az <c>AverageWeightIsValid</c> <c>> 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%) < 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<bool></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<ShippingItemPallet></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>
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user