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.
This commit is contained in:
2026-08-20 21:18:32 +02:00
parent a794293d99
commit 6a90528c1e
14 changed files with 303 additions and 15 deletions
@@ -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 |