7 Commits
Author SHA1 Message Date
Adam b5e57ce659 Merge branch 'FruitBank_v0.0.8.2' of https://git.aycode.com/Adam/Mango.Nop.Plugins into FruitBank_v0.0.8.2 2026-08-19 12:09:40 +02:00
Adam 974fd4e523 Merge branch 'FruitBank_v0.0.8.2' of https://git.aycode.com/Adam/Mango.Nop.Plugins into FruitBank_v0.0.8.2 2026-08-19 00:18:50 +02:00
Adam 761179920b Customer.Company token a vevő cégnevéhez
A Company valódi oszlop a Customer entitáson, a nop mégsem ad rá tokent - cégnevet
csak a rendelés címeiből kínál (Order.BillingCompany), ami B2B-ben más adat.

Az AddCustomerTokensAsync felülírása mindkét hívási utat fedi, mert a nop int-es
túlterhelése a Customer-esbe delegál. Az admin tokenlistája a CustomerTokens
csoportnál egészül ki, így a nop saját vevő- és rendelés-sablonjainál is látszik.

Üres Company esetén a token üresen marad; nincs visszaesés a számlázási címre.
2026-08-19 00:13:02 +02:00
Adam 8a302f8529 A mérendő tétel fizetési tilalma a valós állapotot írja le
A szűrő szakasza eddig működő védelmet dokumentált: az IsPendingMeasurementAsync egy
már nem írt Order-szintű IsMeasurable attribútumot olvas - az OrderDto.IsMeasurable a
tételekből származtatott, nem tárolt érték -, ezért a predikátum mindig false. Vevői
checkout ma nincs; a jövőbeli csak lemért, Audited rendelésre engedhet fizetést.
Szándékosan nem javítjuk, amíg a checkout meg nem születik.

A mérésindítás lépéséből kikerült a téves státusz-állítás, helyette a Started
értesítő szerepel.
2026-08-19 00:05:32 +02:00
Adam 1d86789b65 A mérési értesítők eljutnak a vevőhöz: kiváltás, tokenek, egyszeri küldés
- Az Order.OrderTotal token a saját, mérés utáni végösszeget adja - a nopnak nincs
  ilyen tokenje, a sablonban eddig szó szerint ment ki
- Az admin tokenlistája feltöltve a két FruitBank sablonra (GetTokenGroups,
  GetListOfAllowedTokensAsync)
- A Started értesítőt a StartMeasuring endpoint váltja ki, a tranzakción kívül - a
  mérésindítás nem ír Order-sort, így esemény sem keletkezik
- Az egyszeri küldés a FruitBankNotificationService-be került: a jelölés csak sikeres
  sorbaállítás után íródik, így egy bukott küldés nem némítja el véglegesen a rendelést
- ADR 0003-H7C4, NOTIF topic és a docs/NOTIFICATION/ leírás
2026-08-18 20:04:54 +02:00
Adam 6862c22df4 Merge branch 'FruitBank_v0.0.8.2' of https://git.aycode.com/Adam/Mango.Nop.Plugins into FruitBank_v0.0.8.2 2026-08-16 14:24:45 +02:00
Adam 74cdd90b68 A leltári hiány is fogyaszt beszerzési réteget; a riport Maradt oszlopa javítva
- CostAttributionService: a FIFO-kiosztás közös magra került (AttributeAsync), az eladás és a leltári hiány
  ugyanazt hívja, csak más fogyasztó-fajtával
- AttributeStockTakingLossAsync: csak a HIÁNY fogyaszt — a leltári többlethez nem tartozik beszerzés
- StockTakingDbContext.CloseStockTaking: a készlet-frissítéssel egy eseményből hívja a hozzárendelést
- MarginCalculationService: a Maradt oszlop eddig az adott időszak eladásából számolt, ezért a korábbi hónapok
  fogyását és a leírást is készletnek mutatta; mostantól a réteg teljes fogyasztásából áll elő
- MARGIN/README, STOCK/README, MARGIN_TODO átvezetve
2026-08-16 03:52:49 +02:00
17 changed files with 422 additions and 65 deletions
+1
View File
@@ -16,3 +16,4 @@ Per-repo topic registry for this plugin, per the **per-repo extension convention
| `ORDD` | ORDER-DRAFT | AI-assisted intake channel: inbound free-text messages (Viber/email/dictation) parsed into order drafts, admin review/approval, then PreOrder creation that feeds the existing PRE-ORDER conversion. Covers parsing, product/partner resolution, learning mappings, the draft lifecycle and the approval entry point. | `docs/ORDERDRAFT/` |
| `MARGIN`| ÁRRÉS | Beszerzési önköltség és árrés: a rendelés-tételek ↔ beszerzések (költség-rétegek) hozzárendelése, a fuvar- és egyéb költségek tárolása és szétosztása, deviza- és áfakezelés az árrés szempontjából, valamint a kimutatás dokumentum / kamion / hónap bontásban. | `docs/MARGIN/` |
| `PMDATA`| TERMÉK-TÖRZSADAT | A termék mérési/logisztikai törzsadata (`IsMeasurable`, `Tare`, `AverageWeight`, `AverageWeightTreshold`, `CratesPerPallet`): tárolása, írási útjai, validálása és tömeges szerkesztése. A készlet-vezérelt értékek (`NetWeight`, `IncomingQuantity`) NEM ide tartoznak — azok a `STOCK` és a `PREO` topiké. | `docs/PMDATA/` |
| `NOTIF` | ÉRTESÍTÉS | Vevői e-mail értesítők: a plugin saját `MessageTemplate`-jei, a nop token-provider kiváltása és a saját tokenek, a `MeasuringStatus`-átmenetekhez kötött kiváltás, valamint az ismétlés elleni jelölés. A nop natív értesítői NEM ide tartoznak. | `docs/NOTIFICATION/` |
@@ -19,7 +19,7 @@ using Nop.Services.Catalog;
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
public class CustomOrderSignalREndpoint(FruitBankDbContext ctx, SignalRSendToClientService sendToClient, IPriceCalculationService customPriceCalculationService,IEventPublisher eventPublisher, IWorkContext workContext, IEnumerable<IAcLogWriterBase> logWriters)
public class CustomOrderSignalREndpoint(FruitBankDbContext ctx, SignalRSendToClientService sendToClient, IPriceCalculationService customPriceCalculationService,IEventPublisher eventPublisher, IWorkContext workContext, FruitBankNotificationService fruitBankNotificationService, IEnumerable<IAcLogWriterBase> logWriters)
: ICustomOrderSignalREndpointServer
{
// PROD-on 15 napos adat-ablak; DEV-en 30 nap (a PROD-ból másolt adaton már bőven van mit listázni).
@@ -146,7 +146,29 @@ public class CustomOrderSignalREndpoint(FruitBankDbContext ctx, SignalRSendToCli
_logger.Detail($"StartMeasuring invoked; orderId: {orderId}; userId: {userId}");
if (!await ctx.StartMeasuringSafeAsync(orderId, userId)) return null;
return await ctx.OrderDtos.GetByIdAsync(orderId, true);
var orderDto = await ctx.OrderDtos.GetByIdAsync(orderId, true);
// A mérésindítás csak a MeasurementOwnerId GenericAttribute-ot írja, Order-sort nem — ezért nem keletkezik
// `EntityUpdatedEvent<Order>`, amire a Services/EventConsumer figyel, és a „Started" értesítőnek nincs
// kiváltója. A hívás a tranzakción KÍVÜL áll: ha a mérésindítás visszagördül, ne menjen ki levél.
await SendOrderStartedNotificationAsync(orderDto);
return orderDto;
}
/// <summary>Sends the "order started" customer notification for a just-started measurement.</summary>
private async Task SendOrderStartedNotificationAsync(OrderDto orderDto)
{
if (orderDto == null) return;
var order = ctx.Orders.GetById(orderDto.Id);
if (order == null) return;
// A StartMeasuringSafeAsync akkor is sikert jelez, ha a mérést már korábban elindították (a
// StartMeasuringAsync ilyenkor változatlanul visszaadja a rendelést) — az ismétlés ellen a szolgáltatás
// jelölése véd, nem a hívás eredménye.
await fruitBankNotificationService.SendOrderStartedCustomerNotificationOnceAsync(order, orderDto.IsMeasurable);
}
[SignalR(SignalRTags.SetOrderStatusToComplete, SignalRTags.SendOrderChanged, SendToClientType.Others)]
@@ -1,4 +1,4 @@
#nullable enable
#nullable enable
using System.Threading.Tasks;
using AyCode.Core.Loggers;
using FruitBank.Common.Dtos;
@@ -26,6 +26,7 @@ public class StockTakingDbContext : MgDbContextBase,
IStockTakingItemPalletDbSet<StockTakingItemPalletDbTable>
{
private FruitBankDbContext _fruitBankDbContext;
private readonly CostAttributionService _costAttributionService;
public ProductDtoDbTable ProductDtos { get; set; }
public OrderItemDtoDbTable OrderItemDtos { get; set; }
@@ -44,6 +45,7 @@ public class StockTakingDbContext : MgDbContextBase,
protected readonly IEventPublisher _eventPublisher;
public StockTakingDbContext(INopDataProvider dataProvider, ILockService lockService, IStoreContext storeContext, FruitBankDbContext fruitBankDbContext,
CostAttributionService costAttributionService,
ProductDtoDbTable productDtoDbTable,
OrderItemDtoDbTable orderItemDtoDbTable,
StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos,
@@ -66,6 +68,7 @@ public class StockTakingDbContext : MgDbContextBase,
_staticCacheManager = staticCacheManager;
_fruitBankDbContext = fruitBankDbContext;
_costAttributionService = costAttributionService;
ProductDtos = productDtoDbTable;
OrderItemDtos = orderItemDtoDbTable;
@@ -100,6 +103,11 @@ public class StockTakingDbContext : MgDbContextBase,
$"Leltár által módosítva! stockTakingId: #{stockTaking.Id}, stockTakingItemId: #{stockTakingItem.Id}",
stockTakingItem.NetWeightDiff);
// A leltári hiány ugyanúgy fogyaszt beszerzési réteget, mint az eladás (ADR 0002-R7Q2) — enélkül a
// leírt áru bent maradna a rétegben, és a riport készletnek mutatná. Csak a hiány számít; a többlethez
// nem tartozik beszerzés, azt a szolgáltatás kihagyja.
await _costAttributionService.AttributeStockTakingLossAsync(stockTakingItem, stockTaking.Creator);
Logger.Debug($"CloseStockTaking.UpdateStockQuantityAndWeightAsync; {stockTakingItem.Product}; qntDiff: {stockTakingItem.QuantityDiff} db; netWeightDiff: {stockTakingItem.NetWeightDiff} kg");
}
@@ -13,6 +13,7 @@ using Nop.Core.Domain.Tax;
using Nop.Core.Domain.Vendors;
using Nop.Core.Events;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Attributes;
using Nop.Services.Blogs;
using Nop.Services.Catalog;
@@ -39,6 +40,15 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Infrastructure
{
public class FruitBankMessageTokenProvider : MessageTokenProvider
{
/// <summary>The order total token — the FruitBank-calculated amount stored on the order.</summary>
public const string ORDER_TOTAL_TOKEN = "Order.OrderTotal";
/// <summary>The measurable-order note token — its value is HTML, supplied by the notification service.</summary>
public const string ORDER_MEASURABLE_NOTE_TOKEN = "Order.MeasurableNote";
/// <summary>The customer company token — a real column, but nop ships no token for it.</summary>
public const string CUSTOMER_COMPANY_TOKEN = "Customer.Company";
private readonly IOrderService _orderService;
private readonly IPriceFormatter _priceFormatter;
private readonly ICurrencyService _currencyService;
@@ -145,12 +155,69 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Infrastructure
tokens.Remove(existing);
tokens.Add(new Token("Order.Product(s)", await BuildCustomProductTableAsync(order, languageId), true));
// A nopnak NINCS `Order.OrderTotal` tokenje — a végösszeget csak a termék-tábla lábában jeleníti meg,
// és ott is az árfolyammal átszámított értéket. Nekünk a saját, mérés utáni szám kell: a
// CustomPriceCalculationService.CheckAndUpdateOrderTotalPrice írja az `Order.OrderTotal` oszlopba a
// tételek bruttó (kedvezménnyel csökkentett) árainak összegét — a token ezt az eltárolt értéket adja.
tokens.Add(new Token(ORDER_TOTAL_TOKEN, await FormatOrderTotalAsync(order, languageId)));
}
/// <summary>Adds the customer's company to the customer tokens.</summary>
public override async Task AddCustomerTokensAsync(IList<Token> tokens, Customer customer)
{
await base.AddCustomerTokensAsync(tokens, customer);
// A `Company` valódi oszlop a Customer entitáson, a nop mégsem ad rá tokent — cégnevet csak a rendelés
// címeiből kínál (`Order.BillingCompany`). B2B-ben a vevő cége a rendeléstől független törzsadat, ezért
// saját tokent kap. A bázis `int`-es túlterhelése ide delegál, tehát mindkét hívási út fedve van.
tokens.Add(new Token(CUSTOMER_COMPANY_TOKEN, customer.Company));
}
/// <summary>Maps the FruitBank message templates to their token groups.</summary>
public override IEnumerable<string> GetTokenGroups(MessageTemplate messageTemplate)
{
// A bázis a nop saját `MessageTemplateSystemNames` konstansaira switch-el, és ismeretlen sablonnévre
// ÜRES csoportlistát ad. A `GetListOfAllowedTokensAsync` szűrője az üres listát nem `null`-ként kezeli,
// ezért maradt üresen az admin „Engedélyezett tokenek" doboza a két FruitBank-sablonnál.
if (messageTemplate.Name == FruitBankNotificationService.ORDER_STARTED_TEMPLATE_NAME ||
messageTemplate.Name == FruitBankNotificationService.ORDER_AUDITED_TEMPLATE_NAME)
return [TokenGroupNames.StoreTokens, TokenGroupNames.OrderTokens, TokenGroupNames.CustomerTokens];
return base.GetTokenGroups(messageTemplate);
}
/// <summary>Adds the FruitBank-specific order tokens to the admin's allowed-token list.</summary>
public override async Task<IEnumerable<string>> GetListOfAllowedTokensAsync(IEnumerable<string> tokenGroups = null)
{
var allowedTokens = (await base.GetListOfAllowedTokensAsync(tokenGroups)).ToList();
// A bázis a privát `AllowedTokens` szótárból dolgozik — a saját tokenjeink csak itt kerülhetnek a listába.
if (tokenGroups == null || tokenGroups.Contains(TokenGroupNames.OrderTokens))
{
allowedTokens.Add($"%{ORDER_TOTAL_TOKEN}%");
allowedTokens.Add($"%{ORDER_MEASURABLE_NOTE_TOKEN}%");
}
if (tokenGroups == null || tokenGroups.Contains(TokenGroupNames.CustomerTokens))
allowedTokens.Add($"%{CUSTOMER_COMPANY_TOKEN}%");
return allowedTokens.Distinct();
}
/// <summary>The order's display currency — the customer's, falling back to the primary store currency.</summary>
private async Task<Currency> GetOrderCurrencyAsync(Order order)
=> await _currencyService.GetCurrencyByCodeAsync(order.CustomerCurrencyCode)
?? await _currencyService.GetCurrencyByIdAsync(_currencySettings.PrimaryStoreCurrencyId);
/// <summary>The stored order total, formatted — the single source for both the token and the table footer.</summary>
private async Task<string> FormatOrderTotalAsync(Order order, int languageId)
=> await _priceFormatter.FormatPriceAsync(
order.OrderTotal, true, await GetOrderCurrencyAsync(order), languageId, true);
private async Task<string> BuildCustomProductTableAsync(Order order, int languageId)
{
var currency = await _currencyService.GetCurrencyByCodeAsync(order.CustomerCurrencyCode)
?? await _currencyService.GetCurrencyByIdAsync(_currencySettings.PrimaryStoreCurrencyId);
var currency = await GetOrderCurrencyAsync(order);
var items = await _orderService.GetOrderItemsAsync(order.Id);
var itemDtos = await _dbContext.OrderItemDtos.GetAllByOrderId(order.Id).ToListAsync();
@@ -206,8 +273,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Infrastructure
}
}
var orderTotal = await _priceFormatter.FormatPriceAsync(
order.OrderTotal, true, currency, languageId, true);
var orderTotal = await FormatOrderTotalAsync(order, languageId);
if(itemDtos.Any(i => i.IsMeasurable))
{
@@ -1,5 +1,6 @@
using AyCode.Core.Loggers;
using FruitBank.Common.Dtos;
using FruitBank.Common.Entities;
using LinqToDB;
using Mango.Nop.Core.Loggers;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
@@ -32,27 +33,55 @@ public class CostAttributionService(
/// <summary>Egy rendelés-tétel áruját szétosztja a beszerzési rétegek között; a hívó tranzakciójában fut.</summary>
/// <returns>A létrehozott fogyasztás-sorok száma; 0, ha nem volt mit kiosztani.</returns>
public async Task<int> AttributeOrderItemAsync(OrderItemDto orderItemDto, int? creatorId = null)
public Task<int> AttributeOrderItemAsync(OrderItemDto orderItemDto, int? creatorId = null)
{
ArgumentNullException.ThrowIfNull(orderItemDto);
// Idempotencia: a lezárás nem futhat kétszer ugyanarra a rendelésre, de ha mégis, ne duplázzunk.
var netWeight = double.Round(orderItemDto.NetWeight, 1);
return AttributeAsync(CostConsumerType.OrderItem, orderItemDto.Id, orderItemDto.ProductId,
orderItemDto.IsMeasurable, orderItemDto.Quantity, netWeight, creatorId);
}
/// <summary>Egy leltári HIÁNY áruját szétosztja a beszerzési rétegek között; a hívó tranzakciójában fut.</summary>
/// <remarks>
/// Csak a **hiány** fogyaszt réteget: a leltári többlet (pozitív eltérés) nem, mert ahhoz nem tartozik
/// beszerzés — azt a modell nem tudja hova kötni, és negatív fogyasztásként ábrázolni félrevezető lenne.
/// A többlet így a rétegekben marad, és a következő eladás fogyasztja el.
///
/// Enélkül a leírt áru bent maradna a rétegben, és a riport `Maradt` oszlopa készletnek mutatná.
/// </remarks>
public Task<int> AttributeStockTakingLossAsync(StockTakingItem stockTakingItem, int? creatorId = null)
{
ArgumentNullException.ThrowIfNull(stockTakingItem);
// A hiány pozitív számként: a diff negatív, ha kevesebb van, mint amennyi a nyilvántartás szerint lenne.
var lostQuantity = stockTakingItem.QuantityDiff < 0 ? -stockTakingItem.QuantityDiff : 0;
var lostNetWeight = stockTakingItem.NetWeightDiff < 0 ? double.Round(-stockTakingItem.NetWeightDiff, 1) : 0d;
return AttributeAsync(CostConsumerType.StockTakingItem, stockTakingItem.Id, stockTakingItem.ProductId,
stockTakingItem.IsMeasurable, lostQuantity, lostNetWeight, creatorId);
}
/// <summary>A FIFO-kiosztás közös magja — a fogyasztó fajtájától független.</summary>
private async Task<int> AttributeAsync(CostConsumerType consumerType, int consumerId, int productId,
bool isMeasurable, int quantity, double netWeight, int? creatorId)
{
// Idempotencia: a lezárás nem futhat kétszer ugyanarra a fogyasztóra, de ha mégis, ne duplázzunk.
// A hívás minősítve: az `AnyAsync()` nevet a LinqToDB és a System.Linq aszinkron kiterjesztése is viszi.
var alreadyDone = await LinqToDB.AsyncExtensions.AnyAsync(
costConsumptions.GetAllByConsumer(CostConsumerType.OrderItem, orderItemDto.Id));
costConsumptions.GetAllByConsumer(consumerType, consumerId));
if (alreadyDone) return 0;
var isMeasurable = orderItemDto.IsMeasurable;
// A vezérlő dimenzió: mérendőnél a mért nettó súly, nem mérendőnél a rekeszszám.
var needed = isMeasurable ? double.Round(orderItemDto.NetWeight, 1) : orderItemDto.Quantity;
// A vezérlő dimenzió: mérendőnél a nettó súly, nem mérendőnél a rekeszszám.
var needed = isMeasurable ? netWeight : quantity;
if (needed <= 0) return 0;
var layers = await LoadLayersAsync(orderItemDto.ProductId, isMeasurable);
var layers = await LoadLayersAsync(productId, isMeasurable);
// A tétel összesenjei a MÁSIK dimenzió arányosításához; a vezérlő dimenzió mindig egzakt.
var totals = new ItemTotals(needed, orderItemDto.Quantity, double.Round(orderItemDto.NetWeight, 1));
var totals = new ItemTotals(needed, quantity, netWeight);
var created = 0;
@@ -63,7 +92,7 @@ public class CostAttributionService(
var take = Math.Min(layer.Remaining, needed);
await InsertConsumptionAsync(layer.ShippingItemId, orderItemDto.Id, isMeasurable, take, totals, false, creatorId);
await InsertConsumptionAsync(layer.ShippingItemId, consumerType, consumerId, isMeasurable, take, totals, false, creatorId);
needed -= take;
created++;
@@ -72,11 +101,11 @@ public class CostAttributionService(
// Ami nem talált réteget: ismeretlen forrású. A riport külön során jelenik meg.
if (needed > (isMeasurable ? WeightEpsilon : 0))
{
await InsertConsumptionAsync(null, orderItemDto.Id, isMeasurable, needed, totals, false, creatorId);
await InsertConsumptionAsync(null, consumerType, consumerId, isMeasurable, needed, totals, false, creatorId);
created++;
_logger.Warning($"CostAttributionService: ismeretlen forrású fogyasztás. " +
$"OrderItem.Id: {orderItemDto.Id}, Product.Id: {orderItemDto.ProductId}, " +
$"{consumerType}.Id: {consumerId}, Product.Id: {productId}, " +
$"maradék: {needed:0.###} {(isMeasurable ? "kg" : "rekesz")}");
}
@@ -129,15 +158,16 @@ public class CostAttributionService(
/// egzaktul adódik (FBANKAPP ADR 0001), mérendőnél a rekeszszám a tétel saját összesenjéből. Egyetlen rétegnél
/// — a tipikus eset — az arány 1, tehát mindkét érték pontos; bontásnál a vezérlő dimenzió marad a hiteles.
/// </remarks>
private async Task InsertConsumptionAsync(int? shippingItemId, int orderItemId, bool isMeasurable, double amount, ItemTotals totals, bool isManual, int? creatorId)
private async Task InsertConsumptionAsync(int? shippingItemId, CostConsumerType consumerType, int consumerId,
bool isMeasurable, double amount, ItemTotals totals, bool isManual, int? creatorId)
{
var ratio = totals.Driving > 0 ? amount / totals.Driving : 0d;
var consumption = new CostConsumption
{
ShippingItemId = shippingItemId,
ConsumerType = CostConsumerType.OrderItem,
ConsumerId = orderItemId,
ConsumerType = consumerType,
ConsumerId = consumerId,
Quantity = isMeasurable ? (int)Math.Round(totals.Quantity * ratio) : (int)Math.Round(amount),
NetWeight = isMeasurable ? double.Round(amount, 1) : double.Round(totals.NetWeight * ratio, 1),
IsManual = isManual,
@@ -138,38 +138,16 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
var orderDto = await _dbContext.OrderDtos.GetByIdAsync(eventMessage.Entity.Id, true);
if (orderDto == null) return;
// Az egyszeri küldés (jelölés-ellenőrzés → küldés → jelölés CSAK sikeres sorbaállítás után) a
// FruitBankNotificationService-ben lakik: a `Started` ágat a CustomOrderSignalREndpoint is kiváltja,
// és a két hívónak ugyanazt az idempotenciát kell látnia.
if (orderDto.MeasuringStatus == MeasuringStatus.Audited)
{
var alreadySent = await _fruitBankAttributeService
.GetGenericAttributeValueAsync<Order, bool>(eventMessage.Entity.Id, "OrderAuditedNotificationSent");
await _fruitBankNotificationService
.SendOrderAuditedCustomerNotificationOnceAsync(eventMessage.Entity, orderDto.IsMeasurable);
if (!alreadySent)
{
await _fruitBankNotificationService
.SendOrderAuditedCustomerNotificationAsync(eventMessage.Entity, orderDto.IsMeasurable);
await _fruitBankAttributeService
.InsertOrUpdateGenericAttributeAsync<Order, bool>(
eventMessage.Entity.Id, "OrderAuditedNotificationSent", true);
}
}
else if (orderDto.MeasuringStatus == MeasuringStatus.Started)
{
var alreadySent = await _fruitBankAttributeService
.GetGenericAttributeValueAsync<Order, bool>(eventMessage.Entity.Id, "OrderStartedNotificationSent");
if (!alreadySent)
{
await _fruitBankNotificationService
.SendOrderStartedCustomerNotificationAsync(eventMessage.Entity, orderDto.IsMeasurable);
await _fruitBankAttributeService
.InsertOrUpdateGenericAttributeAsync<Order, bool>(
eventMessage.Entity.Id, "OrderStartedNotificationSent", true);
}
}
await _fruitBankNotificationService
.SendOrderStartedCustomerNotificationOnceAsync(eventMessage.Entity, orderDto.IsMeasurable);
}
@@ -3,6 +3,7 @@ using Nop.Core.Domain.Common;
using Nop.Core.Domain.Messages;
using Nop.Core.Domain.Orders;
using Nop.Core.Events;
using Nop.Plugin.Misc.FruitBankPlugin.Infrastructure;
using Nop.Services.Affiliates;
using Nop.Services.Catalog;
using Nop.Services.Common;
@@ -37,6 +38,7 @@ public class FruitBankNotificationService(
// 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,
FruitBankAttributeService fruitBankAttributeService,
MessagesSettings messagesSettings) : WorkflowMessageService(commonSettings,
emailAccountSettings,
addressService,
@@ -59,6 +61,12 @@ public class FruitBankNotificationService(
public const string ORDER_AUDITED_TEMPLATE_NAME = "FruitBank.OrderAudited.CustomerNotification";
public const string ORDER_STARTED_TEMPLATE_NAME = "FruitBank.OrderStarted.CustomerNotification";
/// <summary>GenericAttribute key guarding the "order started" notification against repeats.</summary>
public const string ORDER_STARTED_SENT_KEY = "OrderStartedNotificationSent";
/// <summary>GenericAttribute key guarding the "order audited" notification against repeats.</summary>
public const string ORDER_AUDITED_SENT_KEY = "OrderAuditedNotificationSent";
@@ -162,6 +170,32 @@ public class FruitBankNotificationService(
return await SendNotificationAsync(ORDER_AUDITED_TEMPLATE_NAME, order, measurableNote);
}
/// <summary>Sends the "order started" notification unless it already went out for this order.</summary>
public Task<bool> SendOrderStartedCustomerNotificationOnceAsync(Order order, bool isMeasurable)
=> SendOnceAsync(order, ORDER_STARTED_SENT_KEY, () => SendOrderStartedCustomerNotificationAsync(order, isMeasurable));
/// <summary>Sends the "order audited" notification unless it already went out for this order.</summary>
public Task<bool> SendOrderAuditedCustomerNotificationOnceAsync(Order order, bool isMeasurable)
=> SendOnceAsync(order, ORDER_AUDITED_SENT_KEY, () => SendOrderAuditedCustomerNotificationAsync(order, isMeasurable));
// ── idempotencia ────────────────────────────────────────────────────────
/// <summary>Runs the send once per order, keyed by a GenericAttribute flag.</summary>
private async Task<bool> SendOnceAsync(Order order, string sentKey, Func<Task<int>> sendAsync)
{
if (await fruitBankAttributeService.GetGenericAttributeValueAsync<Order, bool>(order.Id, sentKey)) return false;
var queuedEmailId = await sendAsync();
// A jelölés CSAK sikeres sorbaállítás után íródik. A küldés minden hibaágon 0-t ad vissza (hiányzó vagy
// inaktív sablon, nincs használható e-mail fiók, nincs vevő), és egy ilyenkor is kiírt jelölés VÉGLEGESEN
// elnémítaná a rendelést: a két értesítőnek nincs második kiváltója, amire újrapróbálkozhatna.
if (queuedEmailId <= 0) return false;
await fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Order, bool>(order.Id, sentKey, true);
return true;
}
// ── shared core ─────────────────────────────────────────────────────────
private async Task<int> SendNotificationAsync(string templateName, Order order, string measurableNote)
@@ -215,7 +249,7 @@ public class FruitBankNotificationService(
await messageTokenProvider.AddCustomerTokensAsync(tokens, customer);
tokens.Add(new Token("Order.MeasurableNote", measurableNote, true));
tokens.Add(new Token(FruitBankMessageTokenProvider.ORDER_MEASURABLE_NOTE_TOKEN, measurableNote, true));
int addressId = 0;
@@ -66,6 +66,11 @@ public class MarginCalculationService(
var (freightByItem, truckSharesByItem) = await CalculateFreightAndTruckSharesAsync(layerIds);
var extraByItem = await CalculateDocumentExtraShareByItemAsync(layers.Select(l => l.ShippingDocumentId).Distinct().ToList());
// A réteg TELJES fogyasztása — minden időszak és minden fogyasztó-fajta, tehát az eladás mellett a
// leltári hiány is. A `Maradt` oszlop csak ebből lehet helyes: az időszak eladásából levezetve azt
// mutatná maradéknak, ami korábbi hónapokban már elfogyott vagy leírásra került.
var consumedTotalByLayer = await LoadTotalConsumptionAsync(layerIds, layers.ToDictionary(l => l.Id, l => l.IsMeasurable));
// ── Soronkénti összeállítás ─────────────────────────────────────────────
var rows = new List<MarginRow>();
@@ -102,9 +107,11 @@ public class MarginCalculationService(
var freightShare = freightByItem.GetValueOrDefault(layer.Id, 0m) * soldRatio;
var extraShare = extraByItem.GetValueOrDefault(layer.Id, 0m) * soldRatio;
var consumedTotal = consumedTotalByLayer.GetValueOrDefault(layer.Id, 0d);
rows.Add(new MarginRow(
layer.Id, layer.ShippingDocumentId, layer.ProductId, layer.Name, isMeasurable,
received, sold, received - sold,
received, sold, received - consumedTotal,
goodsCost, freightShare, extraShare,
revenue, revenue - goodsCost - freightShare - extraShare, false,
truckSharesByItem.GetValueOrDefault(layer.Id, []), group.Key.Period,
@@ -184,6 +191,29 @@ public class MarginCalculationService(
return (result, truckSharesByItem);
}
/// <summary>Rétegenként a TELJES eddigi fogyasztás a vezérlő dimenzióban — minden időszak, minden fajta.</summary>
/// <remarks>
/// Az eladás és a leltári hiány egyaránt fogyaszt, ezért nincs fogyasztó-fajta szűrés. A riport `Maradt`
/// oszlopa ebből áll elő; az időszakra szűrt eladás erre nem alkalmas, mert a korábbi hónapokban elfogyott
/// vagy leírt mennyiséget készletnek mutatná.
/// </remarks>
private async Task<Dictionary<int, double>> LoadTotalConsumptionAsync(List<int> layerIds, Dictionary<int, bool> measurableByLayer)
{
if (layerIds.Count == 0) return [];
var rows = await costConsumptions
.GetAllByShippingItemIds(layerIds)
.Select(c => new { c.ShippingItemId, c.Quantity, c.NetWeight })
.ToListAsync();
return rows
.Where(r => r.ShippingItemId.HasValue)
.GroupBy(r => r.ShippingItemId!.Value)
.ToDictionary(
g => g.Key,
g => measurableByLayer.GetValueOrDefault(g.Key) ? g.Sum(x => x.NetWeight) : g.Sum(x => x.Quantity));
}
/// <summary>A szállítólevél-szintű költségek tételre eső része, ugyanazzal a raklap-kulccsal.</summary>
private async Task<Dictionary<int, decimal>> CalculateDocumentExtraShareByItemAsync(List<int> documentIds)
{
@@ -49,9 +49,13 @@ lépések és az állapotuk.
raklapokra osztva), extra-hányad, árrés — mind lekérdezésből, tárolt pénzügyi érték nélkül.
- [x] **Admin riport** dokumentum- és kamion-bontásban, valamint havonta (a fogyasztási sor `Created` dátuma
szerint), külön soron az „ismeretlen forrású" és a még készleten lévő rész.
- [ ] **Leltári leírás mint fogyasztó** — a `StockTakingDbContext.CloseStockTaking` még nem hív a
- [x] **Leltári leírás mint fogyasztó** — a `StockTakingDbContext.CloseStockTaking` még nem hív a
`CostAttributionService`-be, ezért a leírt áru bent marad a rétegben, és a riport `Maradt` oszlopa
készletnek mutatja. A `CostConsumerType.StockTakingItem` már létezik.
*Kész (2026-08-14): a `CloseStockTaking` a készlet-frissítés mellett hívja az
`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
ott is FIFO dönt. Egy oszlop + egy értékadás a `PreOrderConversionService`-ben.
- [ ] **Futásidejű ellenőrzés: devizás beszerzés önköltsége** — egy ÚJ, EUR-os beszállítótól származó
@@ -21,7 +21,11 @@ legrégebbitől. Ugyanabban a tranzakcióban fut, mint a készlet-levonás.
nem mérendőnél **rekesz**. A sor a másik dimenziót is kitölti a tétel arányában.
- 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 rendelés-tételhez már van fogyasztás, nem keletkezik újabb.
- Idempotens: ha egy fogyasztóhoz már van fogyasztás-sor, nem keletkezik újabb.
Ugyanez a mechanizmus fut a **leltár-záráskor** (`CloseStockTaking`): a hiány FIFO szerint fogyasztja a rétegeket,
`StockTakingItem` fogyasztó-fajtával. A leltári **többlet** nem fogyaszt — ahhoz nem tartozik beszerzés.
A leírás így nem marad készletnek látszó mennyiség a `Maradt` oszlopban.
Tárolt adat egyedül az elfogyasztott mennyiség (`fbCostConsumption`). Minden pénzügyi érték lekérdezés, ezért
egy később érkező helyesbített beszállítói vagy fuvarszámla **visszamenőleg átüt** a riporton.
+13 -9
View File
@@ -73,7 +73,7 @@ OrderDto (order)
1. **Start Measuring**`CustomOrderSignalREndpoint.StartMeasuring(orderId, userId)`
- Sets `MeasurementOwnerId` via GenericAttribute
- Order status set to Processing, Payment to Pending
- Sends the "order started" customer notification — see [`NOTIFICATION/README.md`](NOTIFICATION/README.md)
- Broadcasts `SendOrderChanged`
2. **Weigh pallets**`CustomOrderSignalREndpoint.AddOrUpdateMeasuredOrderItemPallet()`
- Same weight formula as shipping
@@ -100,16 +100,20 @@ valid = percentage < ProductDto.AverageWeightTreshold
`OrderDto.IsAllOrderItemAvgWeightValid` = all items pass this check.
### PendingMeasurementCheckoutFilter
### Payment and the measurable item
ASP.NET Core action filter on the Checkout `ConfirmOrder` action.
**An order containing a measurable item cannot be paid** — the final amount only exists after weighing. There is
currently **no customer-facing checkout** in the system; when one is built (a Stripe integration is the plan), it
may only accept payment for a measured, `Audited` order.
| Step | Action |
|---|---|
| Intercept | `ConfirmOrder` POST in CheckoutController |
| Check | `OrderMeasurementService.IsPendingMeasurementAsync()` |
| If pending | Set OrderStatus=Processing, PaymentStatus=Pending, redirect to PendingMeasurementWarning |
| If not pending | Allow normal checkout flow |
`PendingMeasurementCheckoutFilter` is what remains of that guard, and **today it guards nothing**:
`OrderMeasurementService.IsPendingMeasurementAsync` reads an order-level `IsMeasurable` GenericAttribute that
nobody writes any more — `OrderDto.IsMeasurable` is derived from the items (`OrderItemDtos.Any(oi => oi.IsMeasurable)`)
and never stored — so the predicate is always `false`. The filter is bound to `ConfirmOrder`; were it to fire, it
would redirect to `PendingMeasurementWarning`, and the `Processing` / `Pending` statuses it sets are not persisted.
Deliberately left unfixed while there is no customer checkout: the guard becomes part of that checkout when it is
written, not of this filter.
## Workflow 3: StockTaking
@@ -0,0 +1,55 @@
# NOTIF — ISSUES
> ID format, Status vocabulary, type codes and archival are **not restated here** — see [`TOPIC_CODES.md`](../../.github/TOPIC_CODES.md) (→ framework registry).
Kontextus: [`README.md`](README.md).
---
## MGFBANKPLUG-NOTIF-I-Q3M8: A mérési értesítők kiváltása és idempotencia-jelölése
**Status:** Closed (2026-08-18) · **Priority:** P1 · **Type:** I · **2026-08-18**
Az [ADR 0003-H7C4](../adr/0003-H7C4-dto-event-bridge-wiring.md) horgony-bejegyzése. A rekord a DTO→fő-entitás
esemény-híd bekötését rögzíti; ez a bejegyzés viszi az implementáció állapotát, a rekord *Follow-ups required*
szakasza szerint.
**Mérés (2026-06-01 … 2026-08-19, a javítás előtt):** 3017 rendelés, 2916 lezárt, mind auditált raklappal —
és mindössze 91 „értesítés megkísérelve" jelölés, 13 ténylegesen sorba tett levél.
### Lépések
- [x] **A híd bekötése**`MgDtoDbTableBase` deklarálja a három `IConsumer<>` interfészt; `Mango.Nop.Data`
`Nop.Services` projektreferencia. Ezzel az `Audited` ág kiváltója helyreállt.
- [x] **A `Started` ág triggere** — a mérés indítása (`FruitBankDbContext.StartMeasuringAsync`) csak
`GenericAttribute`-ot ír, `OrderItemDtos`-ra pedig egyetlen írás sincs a pluginban, így a `Started`
ablakban nem keletkezik `EntityUpdatedEvent<Order>`. A `CustomOrderSignalREndpoint.StartMeasuring` hívja
közvetlenül, a mérésindítási tranzakció sikeres lezárulta után, a tranzakción kívül.
- [x] **A jelölés akkor is ráíródott, ha a küldés nem sikerült** — a jelölés így azt jelentette, hogy
„megpróbáltuk", nem azt, hogy „kiment", és mivel ott volt, a rendszer soha nem próbálta újra. Az
idempotencia mostantól egyetlen helyen, a `FruitBankNotificationService.SendOnceAsync`-ben lakik, és
mindkét hívó (a consumer `Audited` ága, valamint a `Started` triggert adó endpoint) azon át megy; a jelölés
csak `queuedEmailId > 0` esetén íródik ki. Következmény: sikertelen küldés után a következő kiváltó
újrapróbálkozik — tartósan hibás sablon- vagy fiók-konfigurációnál ez rendelés-frissítésenként egy újabb
kísérletet jelent, amit a napló rendeléshez kötve jelez.
- [x] **Visszamenőleg elégett jelölések — elfogadott jelenlegi állapot (2026-08-18)** — a sablonok
2026-08-10-i helyreállítása előtt megkísérelt értesítések jelölése ott maradt a rendeléseken. **Nem
takarítjuk:** a jelölés törlése azt eredményezné, hogy a következő Order-frissítésnél kimenne egy
„elkészült, átvehető" levél hetekkel korábban átvett rendelésekre. A hiányzó levelek pótlása ezeknél
nem cél.
### Resolution
Mind a négy lépés lezárva. A kiváltás két oldalról helyreállt — a keretbeli esemény-híd bekötésével
([ADR 0003-H7C4](../adr/0003-H7C4-dto-event-bridge-wiring.md)) az `Audited`, a `StartMeasuring` endpoint
közvetlen hívásával a `Started` ág —, az idempotencia pedig egyetlen helyre került, ahol a jelölés csak
sikeres sorbaállítás után íródik ki. A visszamenőleg elégett jelöléseket szándékosan nem takarítjuk.
A mai állapot leírása: [`README.md`](README.md). **Éles ellenőrzés még hátravan:** a lezárásjelöléslevél
hármas újramérése néhány nap üzem után mondja meg, hogy a három szám együtt mozog-e.
### Érintett
- `Mango.Nop.Data/Repositories/MgDtoDbTableBase.cs`
- `Services/EventConsumer.cs` — a `MeasuringStatus`-elágazás és a jelölés írása
- `Domains/DataLayer/FruitBankDbContext.cs``StartMeasuringAsync`, `SetOrderStatusToCompleteAsync`
@@ -0,0 +1,67 @@
# ÉRTESÍTÉS — vevői e-mail értesítők
> Topic `NOTIF` (prefix `MGFBANKPLUG`) → entry ID-k `MGFBANKPLUG-NOTIF-<I|T|B>-<RAND>`. ID-formátum/Status-szótár → [`TOPIC_CODES.md`](../../.github/TOPIC_CODES.md) (→ keret-registry).
> Companions: [`NOTIFICATION_ISSUES.md`](NOTIFICATION_ISSUES.md) — ismert defektek.
A plugin két saját vevői értesítőt küld a rendelés mérési állapotához kötve, a nop `MessageTemplate` /
`QueuedEmail` gépezetén keresztül. A nop natív értesítői (OrderPlaced stb.) változatlanul működnek; ez a
dokumentum azt írja le, amit a plugin tesz hozzá.
## A két sablon
| Sablon neve | Mikor | Törzs |
|---|---|---|
| `FruitBank.OrderStarted.CustomerNotification` | a rendelés mérése elindult | „elkezdtük feldolgozni" + mérendő tételnél árelőrejelzés |
| `FruitBank.OrderAudited.CustomerNotification` | a rendelés lezárult (`OrderStatus.Complete`) | „elkészült, átvehető" + végösszeg |
A sablonok kanonikus alapértékét a `FruitBankPlugin.InstallAsync` írja be. **Már telepített pluginon ez nem fut
le újra** — ezért éles adatbázisban a sablon kézzel is létrejöhet, eltérő tartalommal. A helyreállító runbook:
[`../sql/fix_order_notification_templates.sql`](../sql/fix_order_notification_templates.sql).
## Kiváltás
A két értesítőt két különböző mechanizmus váltja ki.
A **`Started`** ág kiváltója nem esemény: a mérésindítás csak a `MeasurementOwnerId` GenericAttribute-ot írja,
Order-sort nem, ezért a `CustomOrderSignalREndpoint.StartMeasuring` hívja közvetlenül, a mérésindítási
tranzakció sikeres lezárulta után. A hívás a tranzakción kívül áll: visszagördülő mérésindításnál ne menjen ki
levél.
Az **`Audited`** ág a `Services/EventConsumer`-en át jön, ami az `EntityUpdatedEvent<Order>`-re fut, és a
rendelés **származtatott** `MeasuringStatus`-a alapján ágazik el. Order szinten ez az állapot az `OrderStatus`-ra
kulcsol (`IsComplete``Audited`), nem a raklapok `RevisorId`-jára. A kiváltó maga a lezárás: a
`SetOrderStatusToCompleteAsync` a DTO-táblán ír, ami a DTO→fő-entitás esemény-hídon át
`EntityUpdatedEvent<Order>`-t szül (ADR 0003-H7C4).
Ismétlés ellen rendelésenként egy-egy `GenericAttribute` jelölés véd (`ORDER_STARTED_SENT_KEY`,
`ORDER_AUDITED_SENT_KEY`). A jelölés kezelése egyetlen helyen, a `FruitBankNotificationService.SendOnceAsync`-ben
van, és mindkét hívó azon át megy — a `Started` ágat ugyanis két kiváltó is elérheti.
A jelölés **csak sikeres sorbaállítás után** íródik ki. A küldés minden hibaágon `0`-t ad vissza (hiányzó vagy
inaktív sablon, nincs használható e-mail fiók, nincs vevő); az ilyenkor is kiírt jelölés véglegesen elnémítaná a
rendelést. Sikertelen küldés után a következő kiváltó újrapróbálkozik.
## Tokenek
A `FruitBankMessageTokenProvider` a nop `MessageTokenProvider`-ét váltja ki (`services.Replace`), és négy
ponton tér el tőle:
- `Order.Product(s)` — saját HTML-tábla; mérendő tételnél ár helyett „kalkuláció alatt".
- `Order.OrderTotal` — a nopnak **nincs** ilyen tokenje; a végösszeget csak a termék-tábla lábában jeleníti meg.
A token értéke a `CustomPriceCalculationService` által az `Order.OrderTotal` oszlopba írt szám: bruttó,
tételszinten már érvényesített kedvezménnyel, árfolyam-átszámítás nélkül.
- `Order.MeasurableNote` — HTML-t hordozó token, amit a `FruitBankNotificationService` ad hozzá küldéskor.
Az admin **teszt-küldésében** ezért üres marad.
- `Customer.Company` — a `Company` valódi oszlop a `Customer` entitáson, a nop mégsem ad rá tokent; cégnevet
csak a rendelés címeiből kínál (`Order.BillingCompany`). B2B-ben a vevő cége a rendeléstől független
törzsadat. A token a vevő saját `Company` mezőjét adja, üresen hagyva, ha nincs kitöltve — a számlázási
címre **nem** esik vissza.
A nop az admin „Engedélyezett tokenek" listáját a sablon nevéhez rendelt token-csoportokból állítja elő, és
ismeretlen sablonnévre üres listát ad. A provider ezért felülírja a `GetTokenGroups`-ot a két FruitBank
sablonnévre, és a `GetListOfAllowedTokensAsync`-ben fűzi a listához a két saját tokent.
## Naplózás
A küldés minden kimaradását a nop natív naplójába írjuk (Admin → Rendszer → Napló): hiányzó vagy inaktív
sablon, hiányzó e-mail fiók, nem található vevő. Egy elmaradt vevői levél enélkül nyomtalan maradna.
+1
View File
@@ -23,6 +23,7 @@ Topic documentation for the FruitBank-specific NopCommerce plugin (Layer 3 — c
- [`EKAER/`](EKAER/README.md) — NAV EKÁER reporting, server side: obligation gate, `Shipping`/`Order``tradeCard` mapping, submission (+ `EKAER_ISSUES.md` / `EKAER_TODO.md`)
- [`STOCK/`](STOCK/README.md) — stock movement: quantity + weight running balance, history and its consistency check (+ `STOCK_ISSUES.md`)
- [`MARGIN/`](MARGIN/README.md) — purchase cost and margin: cost layers, FIFO attribution at order completion, freight/extra distribution, and the report (+ `MARGIN_TODO.md` / `MARGIN_ISSUES.md`, and `MARGIN_schema.sql`)
- [`NOTIFICATION/`](NOTIFICATION/README.md) — customer e-mail notifications: the plugin's own message templates, the token-provider replacement, the `MeasuringStatus`-driven trigger and the repeat-guard flags (+ `NOTIFICATION_ISSUES.md`)
- [`PMDATA/`](PMDATA/README.md) — product measurement/logistics master data (`IsMeasurable`, `Tare`, `AverageWeight`, `AverageWeightTreshold`, `CratesPerPallet`): storage, write paths, validation (+ `PMDATA_TODO.md`; the SQL runbook is the flat [`PRODUCT_WEIGHT_DATA_DB_VALIDATION.md`](PRODUCT_WEIGHT_DATA_DB_VALIDATION.md))
## Navigation
@@ -22,7 +22,8 @@ A mechanika (metódusok, hívási sorrend) a [`DATA_LAYER.md`](../DATA_LAYER.md)
**A rendelés-lezárás készlet-mozgása költség-réteget is fogyaszt:** ugyanabban a tranzakcióban a
`CostAttributionService` FIFO szerint hozzárendeli a kiadott árut ahhoz a beszerzéshez, amiből származik
(`fbCostConsumption`) — ebből áll elő az árrés. Lásd [`MARGIN/README.md`](../MARGIN/README.md).
A **leltár-zárás** ezt még nem teszi meg (`MGFBANKPLUG-MARGIN-T-K5V9`).
A **leltár-zárás** ugyanígy fogyaszt: a `CloseStockTaking` a **hiányt** (negatív eltérést) rendeli a rétegekhez.
A leltári **többlet** nem fogyaszt — ahhoz nem tartozik beszerzés, azt a modell nem tudja hova kötni.
## Nyitott munka
File diff suppressed because one or more lines are too long
@@ -21,6 +21,7 @@ Az implementáció állapotát ADR-enként **pontosan egy** `_TODO` / `_ISSUES`
|---|---|---|---|
| [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) | `MGFBANKPLUG-PMDATA-T-H3N8` ([PMDATA_TODO](../PMDATA/PMDATA_TODO.md)) — Open |
| [0002-R7Q2](0002-R7Q2-document-cost-layers-and-margin.md) | A nyereség-kimutatás a szállítólevél szintjén számol, FIFO-val fogyasztott költség-rétegekből, és minden pénzügyi értéket lekérdezéssel állít elő | Proposed (2026-08-13) | `MGFBANKPLUG-MARGIN-T-K5V9` ([MARGIN_TODO](../MARGIN/MARGIN_TODO.md)) — InProgress |
| [0003-H7C4](0003-H7C4-dto-event-bridge-wiring.md) | A DTO-repók esemény-hídja bekötésre kerül a keretben, a `Nop.Services` referencia árán | Accepted (2026-08-18) | `MGFBANKPLUG-NOTIF-I-Q3M8` ([NOTIFICATION_ISSUES](../NOTIFICATION/NOTIFICATION_ISSUES.md)) — Closed |
## Ezt a repót érintő döntések más rétegben