Skip to main content

Inventory on-hand changed

Fires when a committed inventory transaction changes the net physical on-hand quantity of an item in a site and warehouse, and reports the change together with that site and warehouse's current on-hand quantities. One transaction sends one event for every item, site and warehouse it changed, so a transfer reports both its issuing and its receiving warehouse. Downstream systems can use it to push stock levels to e-commerce storefronts, marketplaces, or planning tools without polling. Expect high volume: almost every inventory posting (receipts, shipments, transfers, adjustments, counting, warehouse movements) changes physical on-hand.

Event classMGCLatticeInventOnHandChangedBusinessEvent
Contract classMGCLatticeInventOnHandChangedBusinessEventContract
CategoryInventory management
ModuleModuleAxapta::Inventory
Label IDsInventOnHandChangedBusinessEventName / InventOnHandChangedBusinessEventDescription
TriggerInventUpdateOnhand.onOnHandUpdated delegate, sent after the transaction commits, once per item, site and warehouse whose net physical on-hand changed in the transaction

Trigger / Hook​

  • Handler: MGCLatticeInventOnHandChangedEventHandlers.onOnHandUpdated, decorated with [SubscribesTo(classStr(InventUpdateOnhand), delegateStr(InventUpdateOnhand, onOnHandUpdated))]. Signature: public static void onOnHandUpdated(InventUpdateOnhand _inventUpdateOnhand). It only queues the captured changes (MGCLatticeInventOnHandEventQueue::enqueue, in memory, no database access).
  • Change capture: MGCLatticeInventUpdateOnhand_Class_Extension ([ExtensionOf(classStr(InventUpdateOnhand))]) wraps protected void insertInventSum(). After next insertInventSum(), while the transaction's pending on-hand changes (InventSumDelta rows) still exist, it calls MGCLatticeInventOnHandDeltaCollector::collect(), which sums them per item, site and warehouse for the current SQL transaction. The capture runs while either this event or Item back in stock is activated; one query serves both.
  • Firing condition: after the commit, MGCLatticeInventOnHandEventQueue calls MGCLatticeInventOnHandChangedEventHandlers::raiseOnHandChanged for every captured item, site and warehouse, which sends one event when the net physical inventory change (PhysicalInvent) isn't zero.
  • Timing: InventUpdateOnhand raises onOnHandUpdated from ttsNotifyPreCommit(), once per outermost transaction and legal entity, while the on-hand rows are locked. The events are therefore built and sent after the transaction has committed (SysTransactionScope.ttsCommitted), in the legal entity of the change. A transaction that rolls back sends nothing. Because sending happens after the commit, a failure while sending can't undo the inventory posting: it is reported to telemetry and the other events are still sent (at-most-once delivery).
  • Filtering and volume:
    • Changes are netted per item, site and warehouse. A receipt split across several locations of one warehouse is one event with the total; a move between two locations or license plates of the same warehouse nets to zero and sends nothing; a transfer between warehouses sends one event per warehouse (a negative change for the issuing one, a positive change for the receiving one).
    • Only a non-zero net change in physical inventory counts. Reservation-only changes and financial updates of quantities that are already physically posted don't send an event.
    • No other throttling, batching, parameter, or filter table applies.
  • Quantities: read with InventOnHandQty::newItemDim() using an InventDimParm with only the item, site, and warehouse flags set. They're totals for the item in the reported site and warehouse (all locations, batches, serial numbers, and inventory statuses), read right after the commit, so they include the transaction's changes (and anything committed in between).
  • Enablement gate: BusinessEventsConfigurationReader::isBusinessEventEnabled(classStr(MGCLatticeInventOnHandChangedBusinessEvent)) is checked before the changes are captured and again, in the change's legal entity, before each event is sent.
[SubscribesTo(classStr(InventUpdateOnhand), delegateStr(InventUpdateOnhand, onOnHandUpdated))]
public static void onOnHandUpdated(InventUpdateOnhand _inventUpdateOnhand)
{
Map changes = _inventUpdateOnhand.latticeTakeOnHandChanges();

if (changes)
{
MGCLatticeInventOnHandEventQueue::enqueue(curExt(), changes);
}
}

public static void raiseOnHandChanged(
ItemId _itemId,
InventSiteId _inventSiteId,
InventLocationId _inventLocationId,
InventQty _physicalChange)
{
if (_physicalChange != 0
&& BusinessEventsConfigurationReader::isBusinessEventEnabled(classStr(MGCLatticeInventOnHandChangedBusinessEvent)))
{
MGCLatticeInventOnHandChangedBusinessEvent::newFromSiteWarehouse(_itemId, _inventSiteId, _inventLocationId, _physicalChange).send();
}
}

MGCLatticeInventOnHandEventQueue::enqueue holds the changes until the transaction has committed:

SysGlobalCache scopeCache = transactionScope.parmTransactionScopeCache();
MGCLatticeInventOnHandEventQueue queue = scopeCache.get(classStr(MGCLatticeInventOnHandEventQueue), CacheKey, null);

if (queue == null)
{
queue = new MGCLatticeInventOnHandEventQueue();
scopeCache.set(classStr(MGCLatticeInventOnHandEventQueue), CacheKey, queue);
transactionScope.ttsCommitted += eventhandler(queue.raiseQueued);
}

queue.add(_dataAreaId, _changes);

The changes are summed in MGCLatticeInventOnHandDeltaCollector::collect():

while select ItemId, sum(PhysicalInvent), sum(AvailPhysical) from inventSumDelta
group by ItemId
where inventSumDelta.SQLTransactionID == _sqlTransactionId
join InventSiteId, InventLocationId from inventDim
group by InventSiteId, InventLocationId
where inventDim.inventDimId == inventSumDelta.InventDimId
{
// ... one entry per item, site and warehouse with a non-zero net change ...
}

Payload Contract​

One row per [DataMember], in declaration order. The item, site, warehouse and change come from the captured changes; the quantities are read in initialize() when the event is sent.

JSON fieldDisplay labelType / EDTSourceNotes
legalEntityLegal EntityDataAreaIdstrLwr(curExt())Company whose on-hand changed, in lower case
itemIdItem NumberItemIdCaptured change
siteIdSiteInventSiteIdCaptured change
warehouseIdWarehouseInventLocationIdCaptured change
physicalInventoryChangePhysical Inventory ChangeInventQtySum of the transaction's InventSumDelta.PhysicalInvent for the item, site and warehouseNet change made by this transaction, in the inventory unit; negative for an issue. Never 0
availableOrderedAvailable OrderedInventQtyAvailOrderedInventOnHandQty.availOrdered()Total available, for the item in this site and warehouse
availablePhysicalAvailable PhysicalInventQtyAvailPhysicalInventOnHandQty.availPhysical()Available physical
physicalInventoryPhysical InventoryInventQtyPhysicalOnhandInventOnHandQty.physicalInvent()Physical inventory after the change
onOrderOn OrderInventQtyOnOrderInventOnHandQty.onOrder()On order (open issues)
orderedOrderedInventQtyOrderedInventOnHandQty.ordered()Ordered in total (open receipts)
deductedDeductedInventQtyDeductedInventOnHandQty.deducted()Physically deducted, not yet financially updated
reservedOrderedReserved OrderedInventQtyReservOrderedInventOnHandQty.reservOrdered()Ordered reserved
reservedPhysicalReserved PhysicalInventQtyReservPhysicalInventOnHandQty.reservPhysical()Physical reserved
unitIdStocking UnitUnitOfMeasureSymbolInventTable::find(itemId).inventUnitId()Inventory unit of the item

Every Lattice event also carries the Lattice ordering and correlation envelope, inherited from MGCLatticeBusinessEventContractBase: eventSequence (Int64 - increasing across the whole catalog and all companies, allocated when the event is raised inside the business transaction rather than when it commits; it orders the events of one business record, gaps are permanent and normal, and it must not be used to re-sequence the whole stream), correlationId (String, always Scope:Key - groups emissions that belong to one logical run; Event:<GUID> unique to this emission when the event belongs to no run) and schemaVersion (Int - the payload shape version; 1 for this contract). D365 F&O also adds its standard envelope fields to every business event payload: BusinessEventId, BusinessEventLegalEntity, ContextRecordSubject, ControlNumber, EventId, EventTime, EventTimeIso8601, InitiatingUserAADObjectId, MajorVersion, MinorVersion, and ParentContextRecordSubjects. Do not rely on the order of members in the delivered JSON.

Example Payload​

A receipt of 15 pieces into warehouse 11:

{
"BusinessEventId": "MGCLatticeInventOnHandChangedBusinessEvent",
"ControlNumber": 5637146288,
"EventId": "D41B6E90-2C7F-4A85-8E13-5B9F0A7C3D26",
"EventTime": "\/Date(1790250423000)\/",
"EventTimeIso8601": "2026-09-24T11:47:03.1069177Z",
"MajorVersion": 0,
"MinorVersion": 0,
"BusinessEventLegalEntity": "USMF",
"ContextRecordSubject": "",
"InitiatingUserAADObjectId": "{C1BCAEB3-0BB4-E4EF-9353-EFADE5EDBCBD}",
"ParentContextRecordSubjects": [],
"eventSequence": 48377,
"correlationId": "Event:51df2f59-5268-45e2-83ad-3569ccda6047",
"schemaVersion": 1,
"legalEntity": "usmf",
"itemId": "A0001",
"siteId": "1",
"warehouseId": "11",
"physicalInventoryChange": 15.0,
"availableOrdered": 164.0,
"availablePhysical": 120.0,
"physicalInventory": 135.0,
"onOrder": 15.0,
"ordered": 44.0,
"deducted": 0.0,
"reservedOrdered": 0.0,
"reservedPhysical": 15.0,
"unitId": "ea"
}

Activation​

In the D365FO client, go to System administration > Setup > Business events > Business events catalog. Find this event by name ("Inventory on-hand changed") or by filtering the Category column to Inventory management. Select it and use Activate to bind it to a configured endpoint (for example, an Azure Service Bus queue, Event Grid topic, or HTTPS webhook).

No other setup is needed. Because the event fires for almost every inventory posting, choose an endpoint that can absorb the volume. If you only need to know when an item becomes available again, activate Item Back In Stock instead.