3 Commits
Author SHA1 Message Date
Adam 53bbf66ee9 Merge branch 'FruitBank_v0.0.8.2' of https://git.aycode.com/Adam/Mango.Nop.Plugins into FruitBank_v0.0.8.2 2026-08-20 22:16:55 +02:00
Adam 6a90528c1e Szállítólevél-törlés a tételeivel együtt, és az előrendelés-teljesítés nyilvántartása
A törlés eddig bezárt feltétel-láncon állt: beolvasott dokumentumnak mindig van tétele, tehát a "nincs tétele"
feltétel miatt sosem volt törölhető — és vele a fájl-ág meg az újraimport is elérhetetlen maradt.

- fbPreOrderFulfilment (új tábla, SQL: docs/PREORDER/PREORDER_fulfilment_schema.sql): melyik szállítólevél
  mennyit teljesített egy előrendelés-tételből, és melyik rendelés-tétel lett belőle. Futásonként egy sor a
  delta-mennyiséggel — nem oszlop a PreOrderItem-en, mert egy tételt több dokumentum is feltölthet
- PreOrderConversionService: a közös InsertOrderItemsAsync írja a sort, tehát az első konverzió és a későbbi
  feltöltés is rögzül. Eddig ez az adat csak a rendelés-megjegyzés SZÖVEGÉBEN létezett
- ShippingCleanupService: a "nincs tétele" helyére négy feltétel lépett — nincs lemért raklap, nincs
  kamion-allocation (ADR 0003), nincs előrendelés-teljesítés, nincs befelé irányuló EKÁER. A tételeket és a
  raklapjaikat a meglévő event-kaszkád viszi; a guard az egyetlen védelem, ezért a tranzakción belül újraértékel
- CostAttributionService: konvertált rendelés-tételnél a teljesítő dokumentum rétegei fogynak először, a többi
  utánuk marad — ha a megjelölt dokumentum elfogyott, a maradék a valós készletre kerül, nem "ismeretlen
  forrásúra"
- Entitás, DbTable, DbSet-interfész, DbContext-, DI- és NameCompatibility-bekötés
- PREORDER/README, MARGIN/README, MARGIN_TODO átvezetve

Korlát: teljesítés-sor csak a bevezetés óta keletkezik, a korábbi konverziókat sem a törlés-guard, sem az
árrés forrás-választása nem látja.
2026-08-20 21:18:32 +02:00
Adam a794293d99 Nem mérendő tételnél a súly mindig származtatott, a kliens és a szerver azonos képlettel
- recalcWeights: megszűnt a "csak ha üres" feltétel — a rekeszszám módosítása újraszámol, és az AI kiolvasott
  súlyát is felülírja
- A kliens képlete a szerverével azonos: ceil(Pallets) x raklapsúly, a dokumentum raklapszáma helyett (az az
  AI-tól mindig 1); a tűrés a keret PalletTolerance konstansából jön, nem kézzel írt epszilonból
- selectProduct: a termékkel jön a tare, az averageWeight és az isMeasurable is — eddig csak a cratesPerPallet
  másolódott át, ezért termékcsere vagy kézzel felvett sor után a számítás némán kilépett
- A súlymezők nem mérendő terméknél írásvédettek: ha mindig számolunk, a szerkeszthető mező hazudik
- CreateShippingItem: már a kiolvasáskor a törzsadatból számol, nem csak mentéskor — eddig az admin az AI
  számát látta, a mentés pedig csendben mást írt ki
2026-08-20 21:17:23 +02:00
16 changed files with 354 additions and 24 deletions
@@ -721,6 +721,18 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
// A raklapigény a rekeszszámból; a szállítólevél raklapszáma (mindig 1) csak becslés. Az admin felülírhatja.
shippingItem.Pallets = ShippingCapacityHelper.CalculatePallets(shippingItem);
// Nem mérendőnél a súly a törzsadatból következik, ezért az AI kiolvasott súlyát MÁR ITT eldobjuk —
// ugyanazzal a képlettel, amit a mentés is használ. Enélkül az admin a kiolvasáson még az AI számát
// látná, a mentés pedig csendben mást írna ki (FBANKAPP ADR 0001; Adam, 2026-08-16).
if (!productDto.IsMeasurable)
{
shippingItem.GrossWeightOnDocument =
ShippingCapacityHelper.CeilPallets(shippingItem.Pallets) * FruitBankConstClient.PalletWeightKg
+ shippingItem.QuantityOnDocument * (productDto.Tare + productDto.AverageWeight);
shippingItem.NetWeightOnDocument = shippingItem.QuantityOnDocument * productDto.AverageWeight;
}
return shippingItem;
}
@@ -712,11 +712,11 @@
</div>
<div>
<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}">
<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}" ${isDerivedWeight(item) ? 'readonly title="Nem mérendő termék: a súly a törzsadatból számolódik, kézzel nem írható. A mentés is ezt írja ki."' : ''}>
</div>
<div>
<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}">
<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}" ${isDerivedWeight(item) ? 'readonly title="Nem mérendő termék: a súly a törzsadatból számolódik, kézzel nem írható. A mentés is ezt írja ki."' : ''}>
</div>
<div>
<label>Egységár</label>
@@ -781,7 +781,9 @@
}
// 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))
// A képlet bemenetei a rekeszszám és a raklapszám; a súly-mezők maguk nem váltanak ki számolást
// (nem mérendőnél amúgy is írásvédettek, mérendőnél pedig nincs mit számolni).
if (['palletsOnDocument', 'quantityOnDocument', 'pallets'].includes(field))
recalcWeights(idx);
});
});
@@ -797,6 +799,7 @@
/* 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;
var PALLET_TOLERANCE = @FruitBank.Common.Helpers.ShippingCapacityHelper.PalletTolerance;
/* 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. */
@@ -813,6 +816,16 @@
return (value > 0) ? '<small class="text-muted">(számított)</small>' : '';
}
/* NEM MÉRENDŐ terméknél a súly MINDIG származtatott: a törzsadatból következik, és az megbízhatóbb, mint egy
szállítólevélről kiolvasott szám (FBANKAPP ADR 0001; Adam, 2026-08-16). Ezért nincs „csak ha üres" feltétel —
a rekeszszám módosítása újraszámol, és az AI kiolvasott súlyát is felülírja.
A képlet SZÓ SZERINT a szerverével azonos (FileManagerController mentési ág):
bruttó = ceil(Pallets) × raklapsúly + rekesz × (tára + átlagsúly)
nettó = rekesz × átlagsúly
A raklapsúly a FELKEREKÍTETT raklapszámra jár (a másfél raklapos áru két fizikai raklapon áll), és NEM a
dokumentum raklapszámára — az az AI-tól mindig 1. Korábban a kliens ezzel a két ponton is mást számolt,
mint amit a mentés kiírt. */
function recalcWeights(idx) {
var item = shippingItems[idx];
if (!item || item.isMeasurable) { renderShippingItems(); return; }
@@ -822,15 +835,24 @@
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;
item.grossWeightOnDocument = Math.round((ceilPallets(item.pallets) * PALLET_WEIGHT_KG + crates * (tare + avg)) * 100) / 100;
item.netWeightOnDocument = Math.round((crates * avg) * 100) / 100;
renderShippingItems();
}
/* A ShippingCapacityHelper.CeilPallets párja. A tűrés a keretből jön: a helper kimondja, hogy minden tört
raklapszám-összehasonlítás AZT használja, saját epszilont ne írjunk helyette. */
function ceilPallets(pallets) {
return (pallets > 0) ? Math.ceil(pallets - PALLET_TOLERANCE) : 0;
}
/* Igaz, ha a súly SZÁMÍTOTT, tehát kézzel nem írható: nem mérendő termék, amihez van súly-törzsadat.
Törzsadat híján a mező szerkeszthető marad, különben az admin nem tudná pótolni a hiányt. */
function isDerivedWeight(item) {
return !item.isMeasurable && ((item.tare || 0) > 0 || (item.averageWeight || 0) > 0);
}
function addNewShippingItem() {
shippingItems.push({
name: '', hungarianName: '', nameOnDocument: '', productId: null,
@@ -981,11 +1003,19 @@
// és eltűnik a „nincs rekesz/raklap arány" jelzés (ADR 0004-M9F3). Ha a terméken nincs kitöltve,
// a jelzés marad, a mező pedig kézzel írható.
cratesPerPallet,
// A mérendőség és a súly-törzsadat IS a termékkel jön — a keresőt szolgáló végpont kifejezetten
// ezért küldi. Enélkül a nem mérendő tétel súlya nem számolódna: a recalcWeights azt látná, hogy
// „nincs termék-törzsadat", és némán kilépne.
isMeasurable: product.isMeasurable ?? shippingItems[itemIndex].isMeasurable ?? false,
tare: product.tare ?? 0,
averageWeight: product.averageWeight ?? 0,
pallets: cratesPerPallet > 0 && quantity > 0
? Math.round(quantity / cratesPerPallet * 1000) / 1000
: shippingItems[itemIndex].pallets
};
renderShippingItems();
// A termékváltás új törzsadatot hoz — a súlynak azonnal követnie kell.
recalcWeights(itemIndex);
showMessage(prev ? `Termék módosítva: ${name}` : `Termék párosítva: ${name}`, prev ? 'info' : 'success');
}
@@ -46,7 +46,8 @@ public class FruitBankDbContext : MgDbContextBase,
IFilesDbSet<FilesDbTable>,
ICustomerCreditDbSet<CustomerCreditDbTable>,
IShippingCostDbSet<ShippingCostDbTable>,
ICostConsumptionDbSet<CostConsumptionDbTable>
ICostConsumptionDbSet<CostConsumptionDbTable>,
IPreOrderFulfilmentDbSet<PreOrderFulfilmentDbTable>
{
private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly CostAttributionService _costAttributionService;
@@ -81,6 +82,9 @@ public class FruitBankDbContext : MgDbContextBase,
/// <summary>Költség-réteg fogyasztások (ADR 0002-R7Q2) — az árrés-modell egyetlen tárolt adata.</summary>
public CostConsumptionDbTable CostConsumptions { get; set; }
/// <summary>Előrendelés-teljesítések: melyik szállítólevél mennyit teljesített egy előrendelés-tételből.</summary>
public PreOrderFulfilmentDbTable PreOrderFulfilments { get; set; }
public OrderItemPalletDbTable OrderItemPallets { get; set; }
public FilesDbTable Files { get; set; }
@@ -111,6 +115,7 @@ public class FruitBankDbContext : MgDbContextBase,
OrderDraftDbTable orderDraftDbTable, OrderDraftItemDbTable orderDraftItemDbTable,
ExternalUserPartnerMapDbTable externalUserPartnerMapDbTable, ProductTextMappingDbTable productTextMappingDbTable,
ShippingCostDbTable shippingCostDbTable, CostConsumptionDbTable costConsumptionDbTable,
PreOrderFulfilmentDbTable preOrderFulfilmentDbTable,
CostAttributionService costAttributionService,
IProductService productService, IStaticCacheManager staticCacheManager,
IRepository<Order> orderRepository,
@@ -174,6 +179,7 @@ public class FruitBankDbContext : MgDbContextBase,
ShippingCosts = shippingCostDbTable;
CostConsumptions = costConsumptionDbTable;
PreOrderFulfilments = preOrderFulfilmentDbTable;
_costAttributionService = costAttributionService;
}
@@ -0,0 +1,10 @@
using Mango.Nop.Data.Interfaces;
using Nop.Data;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer.Interfaces;
public interface IPreOrderFulfilmentDbSet<TDbTable> : IMgDbTableBase where TDbTable : IRepository<PreOrderFulfilment>
{
public TDbTable PreOrderFulfilments { get; set; }
}
@@ -0,0 +1,28 @@
using LinqToDB;
using Mango.Nop.Data.Repositories;
using Nop.Core.Caching;
using Nop.Core.Configuration;
using Nop.Core.Events;
using Nop.Data;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
/// <summary>Előrendelés-teljesítések: melyik szállítólevél mennyit teljesített egy előrendelés-tételből.</summary>
public class PreOrderFulfilmentDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings)
: MgDbTableBase<PreOrderFulfilment>(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
{
public override IQueryable<PreOrderFulfilment> GetAll() => base.GetAll();
/// <summary>Egy szállítólevél teljesítései — a törlés-guard erre kérdez rá.</summary>
public IQueryable<PreOrderFulfilment> GetAllByShippingDocumentId(int shippingDocumentId)
=> GetAll().Where(f => f.ShippingDocumentId == shippingDocumentId);
/// <summary>Egy rendelés-tétel forrásai — az árrés ezen át jut el a forrás-dokumentumig.</summary>
public IQueryable<PreOrderFulfilment> GetAllByOrderItemId(int orderItemId)
=> GetAll().Where(f => f.OrderItemId == orderItemId);
/// <summary>Több rendelés-tétel forrásai egy menetben — a riport így kerüli az N+1-et.</summary>
public IQueryable<PreOrderFulfilment> GetAllByOrderItemIds(IReadOnlyCollection<int> orderItemIds)
=> GetAll().Where(f => f.OrderItemId != null && orderItemIds.Contains(f.OrderItemId.Value));
}
@@ -0,0 +1,45 @@
using AyCode.Core.Serializers.Toons;
using AyCode.Interfaces.TimeStampInfo;
using LinqToDB.Mapping;
using Mango.Nop.Core.Entities;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
/// <summary>Egy előrendelés-tétel teljesítése egy konkrét szállítólevélből.</summary>
/// <remarks>
/// Ma ez az adat sehol nem marad meg: a <c>PreOrderConversionService</c> a szállítólevél azonosítóját csak egy
/// rendelés-megjegyzés SZÖVEGÉBE írja. Két fogyasztója van:
/// <list type="number">
/// <item>a szállítólevél törlése — blokkol, ha van rá teljesítés-sor;</item>
/// <item>az árrés (ADR 0002-R7Q2) — a konvertált rendelés-tételnél a forrás így egzakt, nem FIFO-becslés.</item>
/// </list>
///
/// <para>Miért külön sor és nem oszlop a <c>PreOrderItem</c>-en: egy tételt TÖBB dokumentum tölthet fel
/// részletekben (a konverzió a <c>min(Requested Fulfilled, available)</c> deltával dolgozik). Egy oszlop csak
/// az elsőt vagy az utolsót őrizné, és a törlés-guard emiatt alul-blokkolna.</para>
///
/// <para>Nincs <c>[AcBinarySerializable]</c>: ez a típus nem megy a wire-en — admin- és riport-adat.</para>
/// </remarks>
[ToonDescription("Fulfilment of one preorder line from one delivery note",
Purpose = "Records which shipping document fulfilled how much of a preorder item, and which order item it " +
"produced. One preorder item may be topped up across several documents, so this is a row per " +
"conversion run, never a column on the item.")]
[Table(Name = FruitBankPluginConst.PreOrderFulfilmentDbTableName)]
[System.ComponentModel.DataAnnotations.Schema.Table(FruitBankPluginConst.PreOrderFulfilmentDbTableName)]
public sealed class PreOrderFulfilment : MgEntityBase, ITimeStampCreated
{
/// <summary>A teljesített előrendelés-tétel.</summary>
[Column] public int PreOrderItemId { get; set; }
/// <summary>A szállítólevél, amiből a teljesítés történt — a törlés-guard erre kérdez rá.</summary>
[Column] public int ShippingDocumentId { get; set; }
/// <summary>Az ebben a futásban keletkezett rendelés-tétel; ezen át jut el az árrés a forrás-dokumentumig.</summary>
[Column] public int? OrderItemId { get; set; }
/// <summary>Az EBBEN a futásban teljesített mennyiség (delta), nem a kumulált <c>FulfilledQuantity</c>.</summary>
[Column] public int Quantity { get; set; }
/// <summary>Létrehozás UTC időbélyege — a CRUD-hook tölti, kézzel nem állítandó.</summary>
[Column] public DateTime Created { get; set; }
}
@@ -53,5 +53,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
/// <summary>Quantity consumed from one purchase (cost layer).</summary>
public const string CostConsumptionDbTableName = "fbCostConsumption";
/// <summary>Fulfilment of one preorder line from one delivery note.</summary>
public const string PreOrderFulfilmentDbTableName = "fbPreOrderFulfilment";
}
}
@@ -116,6 +116,7 @@ public class PluginNopStartup : INopStartup
services.AddScoped<ShippingItemToShippingDbTable>();
services.AddScoped<ShippingCostDbTable>();
services.AddScoped<CostConsumptionDbTable>();
services.AddScoped<PreOrderFulfilmentDbTable>();
services.AddScoped<CostAttributionService>();
services.AddScoped<ShippingCostService>();
services.AddScoped<MarginCalculationService>();
@@ -59,6 +59,7 @@ public partial class NameCompatibility : INameCompatibility
// Plugin-tulajdonú, a klienssel NEM megosztott táblák (ADR 0002-R7Q2) — a nevük a FruitBankPluginConst-ban.
{ typeof(ShippingCost), FruitBankPluginConst.ShippingCostDbTableName},
{ typeof(CostConsumption), FruitBankPluginConst.CostConsumptionDbTableName},
{ typeof(PreOrderFulfilment), FruitBankPluginConst.PreOrderFulfilmentDbTableName},
};
@@ -24,6 +24,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
public class CostAttributionService(
CostConsumptionDbTable costConsumptions,
ShippingItemDbTable shippingItems,
PreOrderFulfilmentDbTable preOrderFulfilments,
IEnumerable<IAcLogWriterBase> logWriters)
{
private readonly Logger<CostAttributionService> _logger = new(logWriters.ToArray());
@@ -78,7 +79,17 @@ public class CostAttributionService(
var needed = isMeasurable ? netWeight : quantity;
if (needed <= 0) return 0;
var layers = await LoadLayersAsync(productId, isMeasurable);
// Előrendelésből konvertált rendelés-tételnél a forrás NEM tipp: a konverzió rögzítette, melyik
// szállítólevél teljesítette (ADR 0002-R7Q2, 2. pont). Ilyenkor annak a dokumentumnak a rétegei
// fogynak először; a FIFO csak azon belül, illetve utánuk dönt.
var preferredDocumentIds = consumerType == CostConsumerType.OrderItem
? await preOrderFulfilments.GetAllByOrderItemId(consumerId)
.Select(f => f.ShippingDocumentId)
.Distinct()
.ToListAsync()
: [];
var layers = await LoadLayersAsync(productId, isMeasurable, preferredDocumentIds);
// A tétel összesenjei a MÁSIK dimenzió arányosításához; a vezérlő dimenzió mindig egzakt.
var totals = new ItemTotals(needed, quantity, netWeight);
@@ -113,14 +124,19 @@ public class CostAttributionService(
}
/// <summary>A termék beszerzési rétegei, a legrégebbi elöl, a még ki nem adott mennyiségükkel.</summary>
private async Task<List<LayerBalance>> LoadLayersAsync(int productId, bool isMeasurable)
/// <param name="preferredDocumentIds">
/// Előrendelésből konvertált tételnél a teljesítő szállítólevél(ek): ezek rétegei kerülnek előre, FIFO-sorrend
/// szerint egymás között. A többi réteg utánuk marad — nem esik ki, mert ha a megjelölt dokumentum már
/// elfogyott, a maradékot inkább a valós készletre osztjuk, mint „ismeretlen forrásúra".
/// </param>
private async Task<List<LayerBalance>> LoadLayersAsync(int productId, bool isMeasurable, IReadOnlyCollection<int> preferredDocumentIds)
{
// Csak a lemért (bevételezett) tétel lehet réteg: a fajlagos önköltséghez a MÉRT mennyiség az osztó.
var candidates = await shippingItems.GetAll()
.Where(si => si.ProductId == productId && si.IsMeasured)
.OrderBy(si => si.ShippingDocument.ShippingDate)
.ThenBy(si => si.Id)
.Select(si => new { si.Id, si.MeasuredQuantity, si.MeasuredNetWeight })
.Select(si => new { si.Id, si.ShippingDocumentId, si.MeasuredQuantity, si.MeasuredNetWeight })
.ToListAsync();
if (candidates.Count == 0) return [];
@@ -142,6 +158,10 @@ public class CostAttributionService(
.ToDictionary(g => g.Key, g => isMeasurable ? g.Sum(x => x.NetWeight) : g.Sum(x => x.Quantity));
return candidates
// A megjelölt dokumentum rétegei előre; a lekérdezés FIFO-sorrendje ezen belül és a többin is megmarad
// (az OrderBy stabil). Egyetlen sorrend-döntés, nem külön ág — így a maradék automatikusan a normál
// FIFO-n folytatódik.
.OrderByDescending(c => preferredDocumentIds.Contains(c.ShippingDocumentId))
.Select(c =>
{
var capacity = isMeasurable ? c.MeasuredNetWeight : c.MeasuredQuantity;
@@ -11,6 +11,7 @@ using Nop.Core.Domain.Payments;
using Nop.Core.Domain.Shipping;
using Nop.Core.Events;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
using Nop.Services.Catalog;
using Nop.Services.Customers;
using Nop.Services.Orders;
@@ -453,7 +454,7 @@ public partial class PreOrderConversionService
CreatedOrUpdatedDateUTC = DateTime.UtcNow
});
await InsertOrderItemsAsync(order, fulfilledItems, gainedByItemId);
await InsertOrderItemsAsync(order, fulfilledItems, gainedByItemId, shippingDocumentId);
// Recalculate header totals from the actual inserted order items rather than the
// PreOrderItem snapshot price: that snapshot is 0 for OrderDraft-origin preorders
@@ -506,7 +507,7 @@ public partial class PreOrderConversionService
}
// Append new OrderItems for the quantity gained THIS run (delta) only
await InsertOrderItemsAsync(order, newlyFulfilled, gainedByItemId);
await InsertOrderItemsAsync(order, newlyFulfilled, gainedByItemId, shippingDocumentId);
// Recalculate order total from all order items
var allItems = await _dbContext.OrderItems.Table
@@ -535,7 +536,8 @@ public partial class PreOrderConversionService
// ── Shared helpers ────────────────────────────────────────────────────────
private async Task InsertOrderItemsAsync(Order order, List<PreOrderItem> items, IReadOnlyDictionary<int, int> gainedByItemId)
private async Task InsertOrderItemsAsync(Order order, List<PreOrderItem> items, IReadOnlyDictionary<int, int> gainedByItemId,
int shippingDocumentId)
{
var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId);
var store = await _storeContext.GetCurrentStoreAsync();
@@ -597,6 +599,18 @@ public partial class PreOrderConversionService
// Use the service (fires NopCommerce events) instead of direct DB insert
await _orderService.InsertOrderItemAsync(orderItem);
// A teljesítés ténye strukturáltan is rögzül — eddig csak a rendelés-megjegyzés SZÖVEGÉBE került.
// Két fogyasztója van: a szállítólevél törlés-guardja, és az árrés (a konvertált tételnél a forrás
// így egzakt, nem FIFO-becslés). A mennyiség a MOSTANI futás deltája, mert egy tételt több
// dokumentum is feltölthet.
await _dbContext.PreOrderFulfilments.InsertAsync(new PreOrderFulfilment
{
PreOrderItemId = item.Id,
ShippingDocumentId = shippingDocumentId,
OrderItemId = orderItem.Id,
Quantity = quantityToAdd
});
// Deduct from stock — same as CustomOrderController and FruitBankOrderItemService
await _productService.AdjustInventoryAsync(
product,
@@ -23,6 +23,13 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
/// 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>
/// <b>A dokumentum törlése a tételeit is viszi</b> (2026-08-16), ha a kiolvasás eredménye érintetlen: nincs
/// lemérve semmi, nincs kamionra kiosztva, nem teljesített előrendelést, nincs rá EKÁER. Enélkül a feltétel-lánc
/// bezárt: beolvasott dokumentumnak mindig van tétele, tehát sosem lett volna törölhető — és vele a fájl-ág, az
/// újraimport is elérhetetlen maradt.
/// </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)
@@ -67,7 +74,13 @@ public sealed class ShippingCleanupService(FruitBankDbContext ctx, FileStorageSe
#region ShippingDocument
/// <summary>
/// Dokumentum törölhető, ha nincs tétele ÉS nincs rá <b>befelé irányuló</b> EKÁER-bejelentés.
/// Dokumentum törölhető, ha a kiolvasás eredménye még <b>érintetlen</b>: nincs lemérve semmi, nincs kamionra
/// kiosztva, nem teljesített előrendelést, és nincs rá <b>befelé irányuló</b> EKÁER-bejelentés.
/// <para>
/// A törlés a <b>tételeit is elviszi</b> (a kaszkádon át). Ez nem mond ellent annak, hogy egy tétel magában
/// nem törölhető: azt az AI a dokumentumból olvasta ki, egyet nem lehet újragenerálni — az egész
/// kiolvasás-eredmény eldobása viszont értelmes művelet, és a fájl-ág is csak így válik elérhetővé.
/// </para>
/// <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
@@ -81,14 +94,48 @@ public sealed class ShippingCleanupService(FruitBankDbContext ctx, FileStorageSe
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);
var itemIds = await ctx.ShippingItems.GetAll()
.Where(si => si.ShippingDocumentId == shippingDocumentId)
.Select(si => si.Id)
.ToListAsync();
if (itemCount > 0)
return DeleteCheckResult.Deny($"Nem törölhető: {itemCount} tétel tartozik hozzá.");
if (itemIds.Count > 0)
{
// 1. Mérés. A raklap az igazságforrás, de a tétel saját jelzőjét is nézzük: ha bármelyik azt mondja,
// hogy mérés történt, a kiolvasás-eredmény már nem érintetlen.
var measuredPalletCount = await ctx.ShippingItemPallets.GetAll()
.CountAsync(p => itemIds.Contains(p.ShippingItemId) && p.IsMeasured);
// 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.
if (measuredPalletCount > 0)
return DeleteCheckResult.Deny($"Nem törölhető: {measuredPalletCount} lemért raklap tartozik a tételeihez.");
var measuredItemCount = await ctx.ShippingItems.GetAll()
.CountAsync(si => itemIds.Contains(si.Id) && si.IsMeasured);
if (measuredItemCount > 0)
return DeleteCheckResult.Deny($"Nem törölhető: {measuredItemCount} tétele le van mérve.");
// 2. Kamion-allocation (ADR 0003). A tervezőben már beosztott áru törlése a kamion rakományát
// csendben megváltoztatná.
var allocationCount = await ctx.ShippingItemAllocations.GetAll()
.CountAsync(a => itemIds.Contains(a.ShippingItemId));
if (allocationCount > 0)
return DeleteCheckResult.Deny($"Nem törölhető: {allocationCount} tétele kamionhoz van rendelve. Előbb vedd le a szállítmánytervezőben.");
}
// 3. Előrendelés-teljesítés. A konverzió a beérkezett árut rendelésekre osztotta — a törlés ezt nem
// fordítaná vissza. ⚠️ Csak a funkció bevezetése ÓTA keletkezett teljesítésekről van sorunk; a korábbi
// konverziókról nincs, azokat ez a feltétel nem látja.
// Teljes névvel, mint lent az EKÁER-nél: a paraméter nélküli CountAsync kétértelmű.
var fulfilmentCount = await LinqToDB.AsyncExtensions.CountAsync(
ctx.PreOrderFulfilments.GetAllByShippingDocumentId(shippingDocumentId));
if (fulfilmentCount > 0)
return DeleteCheckResult.Deny($"Nem törölhető: {fulfilmentCount} előrendelés-teljesítés származik belőle.");
// 4. EKÁER. 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
@@ -108,6 +155,11 @@ public sealed class ShippingCleanupService(FruitBankDbContext ctx, FileStorageSe
/// 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>
/// <para>
/// A <b>tételeket és a raklapjaikat</b> nem itt töröljük: azokat a <c>FruitBankEventConsumer</c> kaszkádja
/// viszi (dokumentum → tételek → raklapok), ezért megy a törlés <c>publishEvent: true</c>-val. A guard
/// biztosítja, hogy a kaszkád csak érintetlen kiolvasás-eredményt vihessen.
/// </para>
/// </summary>
public async Task<DeleteCheckResult> DeleteShippingDocumentAsync(int shippingDocumentId)
{
@@ -56,8 +56,15 @@ lépések és az állapotuk.
`AttributeStockTakingLossAsync`-et. Csak a **hiány** fogyaszt réteget — a leltári többlethez nem tartozik
beszerzés, azt a modell nem tudja hova kötni. Menet közben javítva a riport `Maradt` oszlopa is: eddig az
időszak eladásából számolt, ezért a korábbi hónapok fogyását és a leírást készletnek mutatta.*
- [ ] **Előrendelés pontos forrása** — a `fbPreOrderItem` nem tárolja, melyik szállítólevél elégítette ki, ezért
- [x] **Előrendelés pontos forrása** — a `fbPreOrderItem` nem tárolja, melyik szállítólevél elégítette ki, ezért
ott is FIFO dönt. Egy oszlop + egy értékadás a `PreOrderConversionService`-ben.
*Kész (2026-08-16), de **nem oszloppal**: egy tételt több dokumentum is feltölthet részletekben, ezért egy
oszlop csak az elsőt vagy az utolsót őrizné. Új tábla: `fbPreOrderFulfilment` (SQL:
[`PREORDER_fulfilment_schema.sql`](../PREORDER/PREORDER_fulfilment_schema.sql)), futásonként egy sor a
delta-mennyiséggel. A `CostAttributionService` a teljesítő dokumentum rétegeit veszi előre; a többi réteg
utánuk marad, tehát ha a megjelölt dokumentum elfogyott, a maradék a valós készletre kerül, nem
„ismeretlen forrásúra". Ugyanez a tábla adja a szállítólevél-törlés guardját.*
⚠️ Csak a bevezetés ÓTA keletkezett konverziókról van sor; a korábbiakra a forrás továbbra is FIFO.
- [ ] **Futásidejű ellenőrzés: devizás beszerzés önköltsége** — egy ÚJ, EUR-os beszállítótól származó
szállítólevélnél igazolni, hogy a dokumentum `FxRate`-je a nop `Currency` táblájából jön (≈357), és hogy az
önköltség a várt nagyságrendbe esik. A régi dokumentumokat **nem** töltjük vissza (ADR 11. pont).
@@ -19,6 +19,9 @@ legrégebbitől. Ugyanabban a tranzakcióban fut, mint a készlet-levonás.
- A fogyasztás **vezérlő** mértékegysége terméktípus-függő, és követi az árazást: mérendőnél **kg**,
nem mérendőnél **rekesz**. A sor a másik dimenziót is kitölti a tétel arányában.
- **Előrendelésből konvertált tételnél a forrás nem tipp:** a konverzió rögzíti, melyik szállítólevél teljesítette
(`fbPreOrderFulfilment`), és annak a dokumentumnak a rétegei fogynak először. A többi réteg utánuk marad, tehát
ha a megjelölt dokumentum elfogyott, a maradék a valós készletre kerül, nem „ismeretlen forrásúra".
- Réteg híján a sor **ismeretlen forrású** (`ShippingItemId = null`), és naplóba is kerül. Nulla önköltséggel
beolvasztani tilos — a riport külön során jelenik meg.
- Idempotens: ha egy fogyasztóhoz már van fogyasztás-sor, nem keletkezik újabb.
@@ -0,0 +1,81 @@
/* =============================================================================
ELŐRENDELÉS-TELJESÍTÉS séma
-----------------------------------------------------------------------------
Egy tábla, amely rögzíti, hogy egy előrendelés-tételből MELYIK szállítólevél
MENNYIT teljesített. Ma ez az adat sehol nem marad meg: a
PreOrderConversionService a shippingDocumentId-t csak egy rendelés-megjegyzés
SZÖVEGÉBE írja (InsertOrderNoteAsync).
Miért tábla és nem oszlop a fbPreOrderItem-en:
egy tételt TÖBB dokumentum is feltölthet részletekben a konverzió a
`min(Requested - Fulfilled, available)` deltával dolgozik, és a kód kommentje
külön kimondja, hogy a partial item topped up across documents is not
deducted/billed twice". Egy oszlop csak az első vagy az utolsó dokumentumot
őrizné, és a törlés-guard emiatt ALUL-BLOKKOLNA: a korábbi dokumentum úgy
tűnne, hogy nem hivatkozza semmi.
Két fogyasztója van:
1. Szállítólevél törlése blokkol, ha van teljesítés-sor.
2. Árrés (ADR 0002-R7Q2) a konvertált rendelés-tételnél a forrás így
EGZAKT, nem FIFO-becslés.
🛡 USE FruitBank_DEV;-re állítva. PROD-hoz írd át a USE-t a PROD adatbázis
nevére. Séma-változás, ezért a futtatás előtt DB-mentés ajánlott.
============================================================================= */
USE FruitBank_DEV; -- PROD-hoz írd át a PROD adatbázis nevére!
SET NOCOUNT ON;
GO
IF OBJECT_ID('dbo.fbPreOrderFulfilment', 'U') IS NULL
BEGIN
CREATE TABLE dbo.fbPreOrderFulfilment
(
Id int IDENTITY(1,1) NOT NULL,
/* Melyik előrendelés-tételt teljesítette. */
PreOrderItemId int NOT NULL,
/* Melyik szállítólevélből. Ez az, amit ma csak a megjegyzés szövege őriz. */
ShippingDocumentId int NOT NULL,
/* A rendelés-tétel, ami ebben a futásban keletkezett vagy bővült.
Az árrés innen jut el a fogyasztótól a forrás-dokumentumig; NULL, ha a
konverzió nem hozott létre rendelés-tételt (elvben nem fordul elő). */
OrderItemId int NULL,
/* Az EBBEN a futásban teljesített mennyiség (delta), nem a kumulált
FulfilledQuantity különben a többszöri feltöltés duplán számolna. */
Quantity int NOT NULL,
Created datetime2(7) NOT NULL CONSTRAINT DF_fbPreOrderFulfilment_Created DEFAULT (SYSUTCDATETIME()),
CONSTRAINT PK_fbPreOrderFulfilment PRIMARY KEY CLUSTERED (Id),
CONSTRAINT CK_fbPreOrderFulfilment_Quantity CHECK (Quantity > 0),
/* A teljesítés ténye a tétellel együtt szűnik meg. */
CONSTRAINT FK_fbPreOrderFulfilment_PreOrderItem FOREIGN KEY (PreOrderItemId)
REFERENCES dbo.fbPreOrderItem (Id) ON DELETE CASCADE
/* A ShippingDocumentId-ra SZÁNDÉKOSAN NINCS FK: éppen az a dolga, hogy a
dokumentum törlését MEGAKADÁLYOZZA az alkalmazás-oldali guard. Egy
kaszkád csendben elvinné a bizonyítékot, egy NO ACTION FK pedig
adatbázis-hibaként csapna le a beszédes üzenet helyett. */
);
/* A törlés-guard kérdése: hivatkozza-e bármi ezt a dokumentumot. */
CREATE INDEX IX_fbPreOrderFulfilment_ShippingDocumentId ON dbo.fbPreOrderFulfilment (ShippingDocumentId);
/* Az árrés kérdése: egy rendelés-tétel melyik dokumentumból jött. */
CREATE INDEX IX_fbPreOrderFulfilment_OrderItemId ON dbo.fbPreOrderFulfilment (OrderItemId) WHERE OrderItemId IS NOT NULL;
CREATE INDEX IX_fbPreOrderFulfilment_PreOrderItemId ON dbo.fbPreOrderFulfilment (PreOrderItemId);
END
GO
/* -----------------------------------------------------------------------------
Ellenőrzés
----------------------------------------------------------------------------- */
SELECT 'fbPreOrderFulfilment' AS Objektum,
CASE WHEN OBJECT_ID('dbo.fbPreOrderFulfilment','U') IS NULL THEN 'HIÁNYZIK' ELSE 'OK' END AS Allapot;
GO
@@ -71,6 +71,23 @@ Header status is **derived from item states** by `PreOrderDbContext.RefreshPreOr
`SweepExpiredPreordersAsync` runs at the start of every conversion run: for preorders past `DateOfReceipt` still holding `Pending`/`PartiallyFulfilled` items, any still-`Pending` item becomes `Dropped` and the header status is refreshed. Already-fulfilled quantities stay (they are already on a real order).
## Teljesítés-nyilvántartás (`fbPreOrderFulfilment`)
A konverzió minden futásban rögzíti, hogy melyik **szállítólevélből** mennyit teljesített egy előrendelés-tételre,
és melyik rendelés-tétel keletkezett belőle. Egy sor = egy futás, a mennyiség az **adott futás deltája** — egy
tételt több dokumentum is feltölthet, ezért ez nem lehet oszlop a `PreOrderItem`-en. Séma:
[`PREORDER_fulfilment_schema.sql`](PREORDER_fulfilment_schema.sql).
Két fogyasztója van:
- **Szállítólevél törlése** — a `ShippingCleanupService` blokkol, ha a dokumentumból származik teljesítés: a
konverzió szétosztotta a beérkezett árut, és a törlés ezt nem fordítaná vissza.
- **Árrés** ([`MARGIN/README.md`](../MARGIN/README.md)) — a konvertált rendelés-tételnél a beszerzési forrás
ebből egzakt, nem FIFO-becslés.
Korábban ez az adat csak a rendelés-megjegyzés **szövegében** szerepelt, tehát megbízhatóan nem volt olvasható.
A bevezetés előtti konverziókról nincs sor.
## Pricing
| Product | Order-item price on conversion |