Material Receipt & Goods Matching

Level: Intermediate Module: Procurement 48 min read Lesson 15 of 55

Overview

  • What you’ll learn:
    • How iDempiere processes Material Receipts using the MInOut model class with MovementType V+ (Vendor Receipts) and DocBaseType MMR (Material Receipt)
    • How receipt lines (MInOutLine) link to purchase order lines, manage locators, and auto-generate lot numbers for attribute set instances
    • How the completeIt() document action creates inventory records via MStorageOnHand and matching records via MMatchPO
    • How the three-way matching system (PO-Receipt-Invoice) works through MMatchPO and MMatchInv records to reconcile purchase orders, goods received, and vendor invoices
    • How to programmatically create receipts, query matching records, verify inventory updates, and handle partial receipts
  • Prerequisites: Procurement Lesson 1 (Purchase Orders & MOrder), Procurement Lesson 2 (Purchase Order Lines & MOrderLine)
  • Source files: org.compiere.model.MInOut, org.compiere.model.MInOutLine, org.compiere.model.MMatchPO, org.compiere.model.MStorageOnHand
  • Estimated reading time: 35 minutes

1. Introduction to Material Receipts

In the iDempiere procurement cycle, a Material Receipt is the document that records the physical arrival of goods from a vendor into your warehouse. After a Purchase Order is issued and the vendor ships the goods, the warehouse team creates a Material Receipt to confirm what was actually received. This document serves three critical functions:

  • Inventory update: The receipt increases on-hand inventory in the designated warehouse and locator, creating or updating MStorageOnHand records.
  • Order fulfillment tracking: The receipt updates the Purchase Order’s delivered quantities, allowing the system to track which PO lines have been partially or fully received.
  • Three-way matching: The receipt creates MMatchPO records that link receipt lines to PO lines. When the vendor invoice arrives, the system can match all three documents — PO, receipt, and invoice — to ensure consistency and detect price variances (Purchase Price Variance, or PPV).

At the code level, a Material Receipt is an instance of MInOut with specific configuration:

// A Material Receipt is an MInOut with:
//   DocBaseType = "MMR" (Material Receipt)
//   isSOTrx = false (this is a purchase-side document)
//   MovementType = "V+" (Vendor Receipts) - set automatically from DocBaseType + isSOTrx

// The MInOut class serves double duty - it handles both:
//   Shipments (outbound documents to customers) and
//   Receipts (inbound documents from vendors)
// The movement type determines the direction of inventory movement.

The class declaration in the source code makes this dual purpose explicit:

/**
 *  Shipment/Receipt Model
 *
 *  @author Jorg Janke
 */
public class MInOut extends X_M_InOut implements DocAction, IDocsPostProcess
{
    // ...
}

The DocAction interface enables the standard document processing workflow (Draft, In Progress, Completed, Voided, Reversed, Closed). The IDocsPostProcess interface allows the receipt to register additional documents (like MMatchPO and MMatchInv records) for posting to the accounting system after the main document is completed.

Receipt vs. Shipment: How iDempiere Distinguishes Them

Since MInOut handles both receipts and shipments, the system uses the combination of the document type’s DocBaseType and the isSOTrx flag to determine the behavior. This distinction affects which direction inventory moves, whether reservation quantities are updated, and what matching records are created.

DocBaseType isSOTrx MovementType Description
MMR (Material Receipt) false V+ (Vendor Receipts) Procurement receipt — goods arriving from vendor
MMR (Material Receipt) true C+ (Customer Returns) Customer returning goods to you
MMS (Material Delivery) true C- (Customer Shipment) Shipping goods to customer
MMS (Material Delivery) false V- (Vendor Returns) Returning goods to vendor

This lesson focuses on the V+ (Vendor Receipts) path — the procurement receipt that creates inbound inventory and triggers PO matching.

2. MInOut Movement Types

The MInOut model defines twelve movement type constants that cover every possible direction of inventory movement in the system. Understanding these constants is essential for working with any shipment or receipt code, because the second character (+ or -) directly controls whether inventory is added to or removed from a locator.

// All 12 movement type constants defined in X_M_InOut (inherited by MInOut)
// The second character determines inventory direction:
//   '+' = inbound (adds to inventory)
//   '-' = outbound (removes from inventory)

// Customer-related
public static final String MOVEMENTTYPE_CustomerReturns    = "C+";  // Customer returns goods to us (inbound)
public static final String MOVEMENTTYPE_CustomerShipment   = "C-";  // We ship goods to customer (outbound)

// Inventory adjustments
public static final String MOVEMENTTYPE_InventoryIn        = "I+";  // Physical inventory increase
public static final String MOVEMENTTYPE_InventoryOut       = "I-";  // Physical inventory decrease

// Warehouse movements (internal transfers)
public static final String MOVEMENTTYPE_MovementTo         = "M+";  // Receiving end of warehouse transfer
public static final String MOVEMENTTYPE_MovementFrom       = "M-";  // Sending end of warehouse transfer

// Production
public static final String MOVEMENTTYPE_ProductionPlus     = "P+";  // Production output (finished goods)
public static final String MOVEMENTTYPE_Production_        = "P-";  // Production consumption (raw materials)

// Vendor (Procurement) - THE FOCUS OF THIS LESSON
public static final String MOVEMENTTYPE_VendorReceipts     = "V+";  // Goods received from vendor (inbound)
public static final String MOVEMENTTYPE_VendorReturns      = "V-";  // Goods returned to vendor (outbound)

// Work Orders
public static final String MOVEMENTTYPE_WorkOrderPlus      = "W+";  // Work order output
public static final String MOVEMENTTYPE_WorkOrder_         = "W-";  // Work order consumption

Movement Type Derivation Logic

The movement type is not set manually by the user — it is derived automatically from the document type when the record is saved. The setMovementType() method in MInOut calls the static getMovementType() method, which reads the document type’s DocBaseType and isSOTrx flag:

/**
 * Derive movement type from document type.
 * Source: MInOut.java, line 1275
 */
public static String getMovementType(Properties ctx, int C_DocType_ID,
        boolean issotrx, String trxName) {
    String movementType = null;
    MDocType docType = MDocType.get(C_DocType_ID);

    if (docType == null) return null;

    if (docType.getDocBaseType().equals(MDocType.DOCBASETYPE_MaterialDelivery))
        movementType = docType.isSOTrx()
            ? MOVEMENTTYPE_CustomerShipment    // MMS + SOTrx=true  -> C-
            : MOVEMENTTYPE_VendorReturns;      // MMS + SOTrx=false -> V-
    else if (docType.getDocBaseType().equals(MDocType.DOCBASETYPE_MaterialReceipt))
        movementType = docType.isSOTrx()
            ? MOVEMENTTYPE_CustomerReturns     // MMR + SOTrx=true  -> C+
            : MOVEMENTTYPE_VendorReceipts;     // MMR + SOTrx=false -> V+

    return movementType;
}

/**
 * Sets Movement Type based on Document Type's DocBaseType and isSOTrx.
 * Called automatically from beforeSave() when C_DocType_ID changes.
 */
public void setMovementType() {
    if (getC_DocType_ID() <= 0) {
        log.saveError("FillMandatory", Msg.translate(getCtx(), "C_DocType_ID"));
        return;
    }
    String movementType = getMovementType(getCtx(), getC_DocType_ID(),
        isSOTrx(), get_TrxName());
    setMovementType(movementType);
}

The beforeSave() method calls setMovementType() whenever the document type changes, ensuring the movement type is always consistent with the document type configuration. This is important: you should never set the movement type directly when creating receipts programmatically — set the document type and let the framework derive it.

How Movement Type Controls Inventory Direction in completeIt()

During document completion, the second character of the movement type string determines whether quantities are positive (inbound) or negative (outbound):

// Inside MInOut.completeIt() - the inventory direction logic
String MovementType = getMovementType();
BigDecimal Qty = sLine.getMovementQty();

if (MovementType.charAt(1) == '-')  // C-, V-, I-, M-, P-, W-
    Qty = Qty.negate();             // Outbound: negate to subtract from inventory

// For V+ (Vendor Receipts), charAt(1) == '+', so Qty remains positive
// MStorageOnHand.add() receives a positive value -> inventory increases

3. Creating Material Receipts

The MInOut class provides multiple constructors for creating receipts, each designed for a different scenario. Understanding which constructor to use depends on whether you are creating a receipt from an existing Purchase Order, from a Vendor Invoice, as a copy of another receipt (for reversals), or from scratch.

Constructors

// Constructor 1: Standard - create a new receipt from scratch
public MInOut(Properties ctx, int M_InOut_ID, String trxName)
// When M_InOut_ID = 0, creates a new empty receipt.
// You must manually set: C_BPartner_ID, M_Warehouse_ID, C_DocType_ID, isSOTrx,
// MovementDate, etc.

// Constructor 2: From Purchase Order - THE MOST COMMON FOR PROCUREMENT
public MInOut(MOrder order, int C_DocTypeShipment_ID, Timestamp movementDate)
// Automatically copies from the order:
//   - C_BPartner_ID, C_BPartner_Location_ID, AD_User_ID
//   - M_Warehouse_ID
//   - isSOTrx (false for PO -> receipt becomes a Material Receipt)
//   - C_Order_ID (links back to the PO)
//   - DeliveryRule, DeliveryViaRule, M_Shipper_ID, FreightCostRule
//   - C_Project_ID, C_Activity_ID, C_Campaign_ID, etc.
//   - Drop ship settings
// Calls setMovementType() which derives V+ from MMR + isSOTrx=false

// Constructor 3: From Invoice
public MInOut(MInvoice invoice, int C_DocTypeShipment_ID,
              Timestamp movementDate, int M_Warehouse_ID)
// Used when creating a receipt directly from a vendor invoice.
// Sets MovementType explicitly:
//   invoice.isSOTrx() ? MOVEMENTTYPE_CustomerShipment : MOVEMENTTYPE_VendorReceipts
// Since vendor invoice has isSOTrx=false, this creates a V+ receipt.

// Constructor 4: Copy - for reversals and counter documents
public MInOut(MInOut original, int C_DocTypeShipment_ID, Timestamp movementDate)
// Creates a new receipt as a copy of an existing one.
// Used during reversal processing to create the reversal document.

The Order-Based Constructor in Detail

The most commonly used constructor for procurement receipts takes a Purchase Order as its source. Here is the full initialization logic:

/**
 * Order Constructor - create header only.
 * Source: MInOut.java, line 624
 */
public MInOut(MOrder order, int C_DocTypeShipment_ID, Timestamp movementDate) {
    this(order.getCtx(), 0, order.get_TrxName());
    setClientOrg(order);
    setC_BPartner_ID(order.getC_BPartner_ID());
    setC_BPartner_Location_ID(order.getC_BPartner_Location_ID());
    setAD_User_ID(order.getAD_User_ID());

    setM_Warehouse_ID(order.getM_Warehouse_ID());
    setIsSOTrx(order.isSOTrx());  // false for Purchase Orders

    if (C_DocTypeShipment_ID == 0) {
        MDocType dto = MDocType.get(getCtx(), order.getC_DocType_ID());
        C_DocTypeShipment_ID = dto.getC_DocTypeShipment_ID();
        if (C_DocTypeShipment_ID <= 0)
            throw new AdempiereException("@NotFound@ @C_DocTypeShipment_ID@");
    }
    setC_DocType_ID(C_DocTypeShipment_ID);
    setMovementType();  // Derives V+ from MMR + isSOTrx=false

    if (movementDate != null)
        setMovementDate(movementDate);
    setDateAcct(getMovementDate());

    // Copy order references
    setC_Order_ID(order.getC_Order_ID());
    setDeliveryRule(order.getDeliveryRule());
    setDeliveryViaRule(order.getDeliveryViaRule());
    setM_Shipper_ID(order.getM_Shipper_ID());
    setFreightCostRule(order.getFreightCostRule());
    setFreightAmt(order.getFreightAmt());
    setSalesRep_ID(order.getSalesRep_ID());
    setC_Activity_ID(order.getC_Activity_ID());
    setC_Campaign_ID(order.getC_Campaign_ID());
    setC_Project_ID(order.getC_Project_ID());
    setDateOrdered(order.getDateOrdered());
    setDescription(order.getDescription());
    setPOReference(order.getPOReference());
    // Drop shipment settings
    setIsDropShip(order.isDropShip());
    setDropShip_BPartner_ID(order.getDropShip_BPartner_ID());
    setDropShip_Location_ID(order.getDropShip_Location_ID());
    setDropShip_User_ID(order.getDropShip_User_ID());
}

Note the critical detail: if you pass C_DocTypeShipment_ID = 0, the constructor looks up the shipment document type from the order’s document type using getC_DocTypeShipment_ID(). For a standard Purchase Order, this returns the Material Receipt document type (DocBaseType MMR).

Static Factory Method: createFrom

For automated receipt creation (e.g., from a scheduled process or workflow), the static factory method provides a higher-level API that handles both header creation and line generation:

/**
 * Create Shipment From Order.
 * Source: MInOut.java, line 336
 */
public static MInOut createFrom(MOrder order, Timestamp movementDate,
        boolean forceDelivery, boolean allAttributeInstances,
        Timestamp minGuaranteeDate, boolean complete, String trxName) {
    if (order == null)
        throw new IllegalArgumentException("No Order");

    if (!forceDelivery
        && DELIVERYRULE_CompleteLine.equals(order.getDeliveryRule()))
        return null;

    // Create header from order
    MInOut retValue = new MInOut(order, 0, movementDate);
    retValue.setDocAction(complete ? DOCACTION_Complete : DOCACTION_Prepare);

    // Create lines based on available stock
    MOrderLine[] oLines = order.getLines(true, "M_Product_ID");
    for (int i = 0; i < oLines.length; i++) {
        BigDecimal qty = oLines[i].getQtyOrdered()
            .subtract(oLines[i].getQtyDelivered());
        if (qty.signum() == 0)
            continue;   // Nothing remaining to deliver

        MProduct product = oLines[i].getProduct();
        if (product != null && product.get_ID() != 0 && product.isStocked()) {
            // Check storage availability based on material policy (FiFo/LiFo)
            String MMPolicy = product.getMMPolicy();
            MStorageOnHand[] storages = MStorageOnHand.getWarehouse(
                order.getCtx(), order.getM_Warehouse_ID(),
                oLines[i].getM_Product_ID(),
                oLines[i].getM_AttributeSetInstance_ID(),
                minGuaranteeDate,
                MClient.MMPOLICY_FiFo.equals(MMPolicy), true, 0, trxName);
            // ... creates MInOutLine for each storage allocation ...
        }
    }
    return retValue;
}

Key MInOut Methods

Method Description
getLines(boolean requery) Returns all MInOutLine[] for this receipt. Pass true to force a database re-read.
getConfirmations(boolean requery) Returns confirmation records (for ship confirm / pick confirm workflows).
setBPartner(MBPartner bp) Sets the business partner and default location/contact from the partner record.
setM_Warehouse_ID(int) Sets the warehouse for this receipt. Must match the organization.
createConfirmation() Creates confirmation records based on the document type’s confirmation settings.
isReversal() Returns true if this receipt is a reversal of another receipt.
isCustomerReturn() Returns true if MovementType is C+ (Customer Returns).
copyLinesFrom(MInOut other, boolean counter, boolean setOrder) Copies all lines from another receipt. Used for reversals and counter documents.
setMovementType() Derives and sets the movement type from DocBaseType + isSOTrx. Called automatically from beforeSave().

Matching SQL Constants

The MInOut class defines SQL template constants used by the Matching window (Info window) to find receipts that are not yet fully matched to POs or invoices. These are critical for the procurement matching workflow:

// Find receipt lines NOT fully matched to Purchase Order lines
public static final String NOT_FULLY_MATCHED_TO_ORDER = BASE_MATCHING_SQL.formatted(
    "SUM(CASE WHEN m.M_InOutLine_ID IS NOT NULL "
    + "THEN COALESCE(m.Qty,0) ELSE 0 END)",
    "M_MatchPO");

// Find receipt lines NOT fully matched to Vendor Invoice lines
public static final String NOT_FULLY_MATCHED_TO_INVOICE = BASE_MATCHING_SQL.formatted(
    "SUM(COALESCE(m.Qty,0))",
    "M_MatchInv");

// Corresponding helper methods for querying
public static List<MatchingRecord> getNotFullyMatchedToOrder(
    int C_BPartner_ID, int M_Product_ID, int C_OrderLine_ID,
    Timestamp from, Timestamp to, String trxName)

public static List<MatchingRecord> getNotFullyMatchedToInvoice(
    int C_BPartner_ID, int M_Product_ID, int C_InvoiceLine_ID,
    Timestamp from, Timestamp to, String trxName)

// The MatchingRecord is a Java record (immutable data carrier)
public static record MatchingRecord(
    int M_InOut_ID, String documentNo, Timestamp documentDate,
    String businessPartnerName, int C_BPartner_ID,
    int line, int M_InOutLine_ID,
    String productName, int M_Product_ID,
    BigDecimal movementQty, BigDecimal matchedQty,
    String organizationName, int AD_Org_ID) {}

These methods support the “Matching PO-Receipt-Invoice” window in the iDempiere UI, where users can see which receipts still need to be matched and manually create or review matches.

4. MInOutLine — Receipt Line Details

Each line on a Material Receipt is an instance of MInOutLine. The receipt line records what product was received, in what quantity, at which warehouse locator, and with what attribute set instance (lot, serial number, etc.). Each line can reference a specific Purchase Order line, creating the link used for matching.

Static Query Methods

The MInOutLine class provides several static methods for finding receipt lines by different criteria:

/**
 * Get receipt/shipment lines for a specific product.
 * Source: MInOutLine.java, line 66
 */
public static MInOutLine[] getOfProduct(Properties ctx,
    int M_Product_ID, String where, String trxName) {
    String whereClause = "M_Product_ID=?"
        + (!Util.isEmpty(where, true) ? " AND " + where : "");
    List<MInOutLine> list = new Query(ctx, Table_Name, whereClause, trxName)
        .setParameters(M_Product_ID)
        .list();
    return list.toArray(new MInOutLine[list.size()]);
}

/**
 * Get receipt/shipment lines for a specific order line.
 * Source: MInOutLine.java, line 84
 */
public static MInOutLine[] getOfOrderLine(Properties ctx,
    int C_OrderLine_ID, String where, String trxName) {
    String whereClause = "C_OrderLine_ID=?"
        + (!Util.isEmpty(where, true) ? " AND " + where : "");
    List<MInOutLine> list = new Query(ctx, Table_Name, whereClause, trxName)
        .setParameters(C_OrderLine_ID)
        .list();
    return list.toArray(new MInOutLine[list.size()]);
}

/**
 * Convenience method - get receipt lines for an order line (no extra where clause).
 * Source: MInOutLine.java, line 119
 */
public static MInOutLine[] get(Properties ctx, int C_OrderLine_ID, String trxName) {
    return getOfOrderLine(ctx, C_OrderLine_ID, null, trxName);
}

/**
 * Get receipt/shipment lines for a specific RMA line.
 */
public static MInOutLine[] getOfRMALine(Properties ctx,
    int M_RMALine_ID, String where, String trxName)

Setting the Order Line Reference

The setOrderLine() method is the primary way to link a receipt line to a Purchase Order line. It copies product, pricing, and dimensional information from the PO line:

/**
 * Set Order Line. Does not set Quantity!
 * Source: MInOutLine.java, line 222
 *
 * @param oLine    order line
 * @param M_Locator_ID  optional locator id (0 = auto-find)
 * @param Qty      used to find locator if M_Locator_ID is 0
 */
public void setOrderLine(MOrderLine oLine, int M_Locator_ID, BigDecimal Qty) {
    setC_OrderLine_ID(oLine.getC_OrderLine_ID());   // Link to PO line
    setLine(oLine.getLine());                         // Copy line number
    setC_UOM_ID(oLine.getC_UOM_ID());               // Unit of measure

    MProduct product = oLine.getProduct();
    if (product == null) {
        // Charge line - no product/ASI/locator
        set_ValueNoCheck("M_Product_ID", null);
        set_ValueNoCheck("M_AttributeSetInstance_ID", null);
        set_ValueNoCheck("M_Locator_ID", null);
    } else {
        setM_Product_ID(oLine.getM_Product_ID());
        setM_AttributeSetInstance_ID(oLine.getM_AttributeSetInstance_ID());

        if (product.isItem()) {
            if (M_Locator_ID == 0)
                setM_Locator_ID(Qty);  // Auto-find locator from storage
            else
                setM_Locator_ID(M_Locator_ID);
        } else {
            set_ValueNoCheck("M_Locator_ID", null);  // Non-stocked items
        }
    }

    // Copy charge and description
    setC_Charge_ID(oLine.getC_Charge_ID());
    setDescription(oLine.getDescription());
    setIsDescription(oLine.isDescription());

    // Copy dimensional accounting references
    setC_Project_ID(oLine.getC_Project_ID());
    setC_ProjectPhase_ID(oLine.getC_ProjectPhase_ID());
    setC_ProjectTask_ID(oLine.getC_ProjectTask_ID());
    setC_Activity_ID(oLine.getC_Activity_ID());
    setC_Campaign_ID(oLine.getC_Campaign_ID());
    setAD_OrgTrx_ID(oLine.getAD_OrgTrx_ID());
    setUser1_ID(oLine.getUser1_ID());
    setUser2_ID(oLine.getUser2_ID());
    setC_CostCenter_ID(oLine.getC_CostCenter_ID());
    setC_Department_ID(oLine.getC_Department_ID());
}

Note that setOrderLine() does not set the quantity. You must call setQty() or setMovementQty()/setQtyEntered() separately. This design allows you to receive a different quantity than was ordered (partial receipt).

Setting the Invoice Line Reference

Similarly, setInvoiceLine() links a receipt line to a vendor invoice line. The structure mirrors setOrderLine():

/**
 * Set Invoice Line. Does not set Quantity!
 * Source: MInOutLine.java, line 272
 */
public void setInvoiceLine(MInvoiceLine iLine, int M_Locator_ID, BigDecimal Qty) {
    setC_OrderLine_ID(iLine.getC_OrderLine_ID());  // Inherit order link from invoice
    setLine(iLine.getLine());
    setC_UOM_ID(iLine.getC_UOM_ID());

    int M_Product_ID = iLine.getM_Product_ID();
    if (M_Product_ID == 0) {
        set_ValueNoCheck("M_Product_ID", null);
        set_ValueNoCheck("M_Locator_ID", null);
        set_ValueNoCheck("M_AttributeSetInstance_ID", null);
    } else {
        setM_Product_ID(M_Product_ID);
        setM_AttributeSetInstance_ID(iLine.getM_AttributeSetInstance_ID());
        if (M_Locator_ID == 0)
            setM_Locator_ID(Qty);   // Auto-find locator
        else
            setM_Locator_ID(M_Locator_ID);
    }
    // ... copies charge, description, dimensional references ...
}

Locator Auto-Resolution

When you pass M_Locator_ID = 0 to setOrderLine() or setInvoiceLine(), the overloaded setM_Locator_ID(BigDecimal Qty) method is called, which finds the appropriate locator automatically:

/**
 * Set (default) Locator based on qty. Assumes Warehouse is set.
 * Source: MInOutLine.java, line 348
 */
public void setM_Locator_ID(BigDecimal Qty) {
    // If locator is already set, keep it
    if (getM_Locator_ID() != 0)
        return;
    // No product -> no locator needed
    if (getM_Product_ID() == 0) {
        set_ValueNoCheck(COLUMNNAME_M_Locator_ID, null);
        return;
    }

    // Try to find existing storage location for this product
    int M_Locator_ID = MStorageOnHand.getM_Locator_ID(
        getM_Warehouse_ID(), getM_Product_ID(),
        getM_AttributeSetInstance_ID(), Qty, get_TrxName());

    // Fallback: use the warehouse's default locator
    if (M_Locator_ID == 0) {
        MWarehouse wh = MWarehouse.get(getCtx(), getM_Warehouse_ID());
        M_Locator_ID = wh.getDefaultLocator().getM_Locator_ID();
    }
    setM_Locator_ID(M_Locator_ID);
}

Quantity Methods

The MInOutLine manages two quantity fields — QtyEntered (in the entered UOM) and MovementQty (in the product’s stocking UOM). Both enforce UOM precision:

/**
 * Set Entered and Movement Qty (convenience method).
 * Source: MInOutLine.java, line 377
 */
public void setQty(BigDecimal Qty) {
    setQtyEntered(Qty);
    setMovementQty(getQtyEntered());
}

/**
 * Set Qty Entered - enforce UOM precision.
 */
@Override
public void setQtyEntered(BigDecimal QtyEntered) {
    if (QtyEntered != null && getC_UOM_ID() != 0) {
        int precision = MUOM.getPrecision(getCtx(), getC_UOM_ID());
        QtyEntered = QtyEntered.setScale(precision, RoundingMode.HALF_UP);
    }
    super.setQtyEntered(QtyEntered);
}

/**
 * Set Movement Qty - enforce Product UOM precision.
 */
@Override
public void setMovementQty(BigDecimal MovementQty) {
    MProduct product = getProduct();
    if (MovementQty != null && product != null) {
        int precision = product.getUOMPrecision();
        MovementQty = MovementQty.setScale(precision, RoundingMode.HALF_UP);
    }
    super.setMovementQty(MovementQty);
}

Auto-Generate Lot on Receipt

One of the most useful features for procurement is automatic lot number generation. When a product’s attribute set is configured with IsAutoGenerateLot = true, the system automatically creates a new MAttributeSetInstance with a generated lot number when the receipt line is saved. This happens in the beforeSave() method:

// Inside MInOutLine.beforeSave() - Source: MInOutLine.java, line 687
boolean isAutoGenerateLot = false;
if (attributeset != null)
    isAutoGenerateLot = attributeset.isAutoGenerateLot();

if (getReversalLine_ID() == 0                          // Not a reversal
    && !getParent().isSOTrx()                          // Purchase side only
    && !getParent().getMovementType()
        .equals(MInOut.MOVEMENTTYPE_VendorReturns)     // Not a vendor return
    && isAutoGenerateLot                               // Attribute set configured
    && getM_AttributeSetInstance_ID() == 0)            // No ASI assigned yet
{
    MProduct product = MProduct.get(getCtx(), getM_Product_ID());
    MAttributeSetInstance asi = MAttributeSetInstance.generateLot(
        getCtx(), product, get_TrxName());
    setM_AttributeSetInstance_ID(asi.getM_AttributeSetInstance_ID());
}

The conditions are significant: auto-generation only occurs for purchase-side receipts (not sales transactions), excludes vendor returns (which should reference the original lot), excludes reversals (which reference the original receipt’s ASI), and only fires when no ASI has been explicitly assigned. This ensures lot numbers are generated exactly once for incoming inventory.

MInOutLine Key Methods Reference

Method Description
setOrderLine(MOrderLine, int, BigDecimal) Links to PO line, copies product/ASI/locator/dimensions. Does not set quantity.
setInvoiceLine(MInvoiceLine, int, BigDecimal) Links to invoice line, copies product/ASI/locator/dimensions. Does not set quantity.
setQty(BigDecimal) Sets both QtyEntered and MovementQty to the same value.
setQtyEntered(BigDecimal) Sets QtyEntered with UOM precision enforcement.
setMovementQty(BigDecimal) Sets MovementQty with product UOM precision enforcement.
setM_Locator_ID(BigDecimal) Auto-finds locator from existing storage or warehouse default.
setM_Locator_ID(int) Sets a specific locator ID. Throws IllegalArgumentException if negative.
getParent() Returns the parent MInOut header record (lazy-loaded).
getProduct() Returns the MProduct for this line (lazy-loaded, cached copy).
getM_Warehouse_ID() Returns the warehouse ID (inherited from parent if not set on line).

5. CompleteIt Receipt Logic

The completeIt() method in MInOut is the heart of the receipt processing engine. When a procurement receipt (V+) is completed, the method performs a carefully orchestrated sequence of operations for each receipt line: material policy checks, inventory updates, reservation adjustments, order line updates, and matching record creation.

Document Completion Flow

The high-level flow of completeIt() is:

/**
 * Complete the receipt document.
 * Source: MInOut.java, line 1630
 */
public String completeIt() {
    // 1. Re-check preparation if needed
    if (!m_justPrepared) {
        String status = prepareIt();
        if (!DocAction.STATUS_InProgress.equals(status))
            return status;
    }

    // 2. Set definite document number
    setDefiniteDocumentNo();

    // 3. Fire BEFORE_COMPLETE model validators
    m_processMsg = ModelValidationEngine.get()
        .fireDocValidate(this, ModelValidator.TIMING_BEFORE_COMPLETE);
    if (m_processMsg != null)
        return DocAction.STATUS_Invalid;

    // 4. Check pending confirmations (Ship Confirm / Pick Confirm)
    if (pendingCustomerConfirmations()) {
        m_processMsg = "@Open@: @M_InOutConfirm_ID@";
        return DocAction.STATUS_InProgress;
    }

    // 5. Implicit approval
    if (!isApproved())
        approveIt();

    // 6. Period closed check for back-dated transactions
    if (!isReversal()) {
        periodClosedCheckForBackDateTrx(null);
    }

    // 7. Stock coverage check for back-dated transactions
    if (!isReversal() && !stockCoverageCheckForBackDateTrx(null)) {
        m_processMsg = "@InsufficientStockCoverage@";
        return DocAction.STATUS_Invalid;
    }

    // 8. Process each line (inventory, reservations, matching)
    MInOutLine[] lines = getLines(false);
    for (MInOutLine sLine : lines) {
        // ... detailed processing per line (see below) ...
    }

    // 9. Create counter document (if configured)
    MInOut counter = createCounterDoc();

    // 10. Create drop shipment (if applicable)
    MInOut dropShipment = createDropShipment();

    // 11. Fire AFTER_COMPLETE model validators
    // 12. Set document status to Completed

    return DocAction.STATUS_Completed;
}

Per-Line Processing: Inventory Updates

For each receipt line, the method determines the inventory direction and updates storage:

// Inside the per-line loop in completeIt()
MInOutLine sLine = lines[lineIndex];
MProduct product = sLine.getProduct();

// Determine inventory direction from movement type
String MovementType = getMovementType();   // "V+" for procurement receipt
BigDecimal Qty = sLine.getMovementQty();
if (MovementType.charAt(1) == '-')         // Not for V+
    Qty = Qty.negate();
// For V+: Qty remains positive -> adds to inventory

// Load the linked order line (if any)
MOrderLine oLine = null;
if (sLine.getC_OrderLine_ID() != 0)
    oLine = new MOrderLine(getCtx(), sLine.getC_OrderLine_ID(), get_TrxName());

// For stocked products: update inventory
if (product != null && product.isStocked()) {
    // Check material policy (unless reversal)
    if (!isReversal()) {
        BigDecimal movementQty = sLine.getMovementQty();
        BigDecimal qtyOnLineMA = MInOutLineMA.getManualQty(
            sLine.getM_InOutLine_ID(), get_TrxName());
        checkMaterialPolicy(sLine, movementQty.subtract(qtyOnLineMA));
    }

    // Update MStorageOnHand - the actual inventory record
    if (!MStorageOnHand.add(getCtx(),
        sLine.getM_Locator_ID(),
        sLine.getM_Product_ID(),
        sLine.getM_AttributeSetInstance_ID(),
        Qty,                      // Positive for V+ receipt
        dateMPolicy,              // Material policy date
        get_TrxName()))
    {
        m_processMsg = "Cannot correct Inventory OnHand ["
            + product.getValue() + "]";
        return DocAction.STATUS_Invalid;
    }

    // Create Material Transaction record (M_Transaction)
    MTransaction mtrx = new MTransaction(getCtx(),
        sLine.getAD_Org_ID(), MovementType,
        sLine.getM_Locator_ID(), sLine.getM_Product_ID(),
        sLine.getM_AttributeSetInstance_ID(),
        Qty, getMovementDate(), get_TrxName());
    mtrx.setM_InOutLine_ID(sLine.getM_InOutLine_ID());
    mtrx.save();
}

Per-Line Processing: Reservation Adjustments

When a receipt line is linked to a Purchase Order line, the system reduces the order’s reserved (ordered) quantity:

// Update reservation quantities
if (oLine != null && oLine.getM_Product_ID() > 0 && !orderClosed) {
    BigDecimal storageReservationToUpdate = sLine.getMovementQty();
    if (!isReversal()) {
        // Don't unreserve more than what's actually reserved
        if (storageReservationToUpdate.compareTo(oLine.getQtyReserved()) > 0)
            storageReservationToUpdate = oLine.getQtyReserved();
    }

    // Decrease MStorageReservation (ordered quantity)
    MStorageReservation.add(getCtx(), oLine.getM_Warehouse_ID(),
        oLine.getM_Product_ID(),
        oLine.getM_AttributeSetInstance_ID(),
        storageReservationToUpdate.negate(),   // Negative = decrease
        isSOTrx(),                              // false for PO
        get_TrxName(), tracer);
}

Per-Line Processing: Order Line Updates

For procurement receipts, the order line update follows a specific rule captured in the source code comment:

// Update Sales/Purchase Order Line
if (oLine != null) {
    if (isSOTrx()                          // For sales: update delivered qty here
        || sLine.getM_Product_ID() == 0)   // For PO charges/empty lines: update here
    {
        // "PO is done by Matching" - this comment in the source code explains:
        // For PO lines WITH products, the delivered qty is updated by the
        // MMatchPO matching process, NOT here in completeIt().
        // Only SO lines and PO charge lines update delivered qty directly.

        if (isSOTrx())
            oLine.setQtyDelivered(oLine.getQtyDelivered().subtract(Qty));
        else
            oLine.setQtyDelivered(oLine.getQtyDelivered().add(Qty));
        oLine.setDateDelivered(getMovementDate());
    }
    oLine.save();
}

This is a crucial architectural detail: for Purchase Order lines with products, the delivered quantity update is handled by the MMatchPO matching process, not by the receipt completion directly. This ensures the delivered quantity is only updated when matching is confirmed. For PO lines without products (charge lines) and for sales order lines, the update happens directly during completion.

Per-Line Processing: PO Matching

The matching logic only runs for purchase-side documents (!isSOTrx()) with products and when the receipt is not a reversal:

// Matching - only for purchase-side receipts with products
if (!isSOTrx()
    && sLine.getM_Product_ID() != 0
    && !isReversal())
{
    BigDecimal matchQty = sLine.getMovementQty();

    // Step 1: Try Invoice-Receipt matching (MMatchInv)
    MInvoiceLine iLine = MInvoiceLine.getOfInOutLine(sLine);
    if (iLine != null && iLine.getM_Product_ID() != 0) {
        if (matchQty.compareTo(iLine.getQtyInvoiced()) > 0)
            matchQty = iLine.getQtyInvoiced();

        MMatchInv[] matches = MMatchInv.get(getCtx(),
            sLine.getM_InOutLine_ID(),
            iLine.getC_InvoiceLine_ID(), get_TrxName());

        if (matches == null || matches.length == 0) {
            MMatchInv inv = new MMatchInv(iLine, getMovementDate(), matchQty);
            inv.save(get_TrxName());
            addDocsPostProcess(inv);    // Queue for accounting post
        }
    }

    // Step 2: Create PO-Receipt matching (MMatchPO)
    if (sLine.getC_OrderLine_ID() != 0) {
        log.fine("PO Matching");
        MMatchPO po = MMatchPO.create(null, sLine, getMovementDate(), matchQty);
        if (po != null) {
            po.save(get_TrxName());
            if (!po.isPosted())
                addDocsPostProcess(po);  // Queue for accounting post
        }

        // Update PO line ASI from receipt (if full match and PO has no ASI)
        if (oLine != null
            && oLine.getM_AttributeSetInstance_ID() == 0
            && sLine.getMovementQty().compareTo(oLine.getQtyOrdered()) == 0)
        {
            oLine.setM_AttributeSetInstance_ID(
                sLine.getM_AttributeSetInstance_ID());
            oLine.saveEx(get_TrxName());
        }
    }
    else  // No direct order link - try via invoice
    {
        if (iLine != null && iLine.getC_OrderLine_ID() != 0) {
            log.fine("PO(Inv) Matching");
            MMatchPO po = MMatchPO.create(iLine, sLine,
                getMovementDate(), matchQty);
            if (po != null) {
                po.save(get_TrxName());
                if (!po.isPosted())
                    addDocsPostProcess(po);
            }
        }
    }
}  // PO Matching

The matching logic handles two scenarios:

  1. Direct link: The receipt line has C_OrderLine_ID set (created from a PO). A MMatchPO is created linking the receipt line to the PO line.
  2. Indirect link via invoice: The receipt line has no PO link, but a matching invoice line has a PO link. The system creates a MMatchPO using the invoice line’s order reference.

Drop Shipment Handling

After all lines are processed, completeIt() checks for drop shipment scenarios. If the PO is linked to a Sales Order (via Link_Order_ID), the system automatically generates a Customer Shipment:

// After line processing in completeIt()
MInOut dropShipment = createDropShipment();
if (dropShipment != null) {
    info.append(" - @DropShipment@: @M_InOut_ID@=")
        .append(dropShipment.getDocumentNo());
}

// The createDropShipment() method checks:
// 1. Does the PO (source order) have a linked Sales Order?
//    MOrder sOrder = ... ; if (sOrder.getLink_Order_ID() > 0)
// 2. If yes, create a Customer Shipment (C-) for the linked SO

6. MMatchPO — PO-Receipt Matching

The MMatchPO model class represents the M_MatchPO table, which is the cornerstone of iDempiere’s three-way matching system. Each MMatchPO record links a receipt line to a purchase order line, tracking the matched quantity. When an invoice line is also present, the match links all three documents. Purchase Price Variance (PPV) is calculated when both PO and invoice prices are available.

From the source code Javadoc:

/**
 * Match PO Model.
 * <pre>
 * Created when processing Shipment or Order
 * - Updates Order (delivered, invoiced)
 * - Creates PPV acct
 * </pre>
 */
public class MMatchPO extends X_M_MatchPO
{
    // ...
}

Static Query Methods

MMatchPO provides multiple ways to query existing match records:

/**
 * Get PO Match with order/invoice.
 * Returns matches that link a specific order line to a specific invoice line.
 */
public static MMatchPO[] get(Properties ctx,
    int C_OrderLine_ID, int C_InvoiceLine_ID, String trxName)
{
    if (C_OrderLine_ID == 0 || C_InvoiceLine_ID == 0)
        return new MMatchPO[]{};
    String sql = "SELECT * FROM M_MatchPO "
        + "WHERE C_OrderLine_ID=? AND C_InvoiceLine_ID=?";
    // ... executes query and returns array ...
}

/**
 * Get PO Match of Receipt Line.
 * Returns all matches linked to a specific receipt line.
 */
public static MMatchPO[] get(Properties ctx,
    int M_InOutLine_ID, String trxName)
{
    if (M_InOutLine_ID == 0)
        return new MMatchPO[]{};
    String sql = "SELECT * FROM M_MatchPO WHERE M_InOutLine_ID=?";
    // ...
}

/**
 * Get PO Matches of receipt (all lines).
 * Joins M_MatchPO to M_InOutLine to find all matches for a receipt document.
 */
public static MMatchPO[] getInOut(Properties ctx,
    int M_InOut_ID, String trxName)
{
    if (M_InOut_ID == 0)
        return new MMatchPO[]{};
    String sql = "SELECT * FROM M_MatchPO m"
        + " INNER JOIN M_InOutLine l ON (m.M_InOutLine_ID=l.M_InOutLine_ID)"
        + " WHERE l.M_InOut_ID=?";
    // ...
}

/**
 * Get PO Matches of Invoice (all lines).
 * Joins M_MatchPO to C_InvoiceLine to find all matches for an invoice.
 */
public static MMatchPO[] getInvoice(Properties ctx,
    int C_Invoice_ID, String trxName)
{
    if (C_Invoice_ID == 0)
        return new MMatchPO[]{};
    String sql = "SELECT * FROM M_MatchPO mi"
        + " INNER JOIN C_InvoiceLine il "
        + "   ON (mi.C_InvoiceLine_ID=il.C_InvoiceLine_ID)"
        + " WHERE il.C_Invoice_ID=?";
    // ...
}

/**
 * Get PO Matches for OrderLine.
 * Returns all matches linked to a specific PO line.
 */
public static MMatchPO[] getOrderLine(Properties ctx,
    int C_OrderLine_ID, String trxName)
{
    if (C_OrderLine_ID == 0)
        return new MMatchPO[]{};
    String sql = "SELECT * FROM M_MatchPO WHERE C_OrderLine_ID=?";
    // ...
}

Factory and Create Methods

The create() static methods are the primary way to create or update match records. The logic is sophisticated: it first tries to find existing unmatched records to update before creating new ones.

/**
 * Update or Create Match PO record.
 * Source: MMatchPO.java, line 294
 *
 * @param iLine     invoice line (may be null)
 * @param sLine     receipt line (may be null)
 * @param dateTrx   transaction date
 * @param qty       qty to match
 * @return Match PO Record
 */
public static MMatchPO create(MInvoiceLine iLine, MInOutLine sLine,
    Timestamp dateTrx, BigDecimal qty)
{
    // Determine the order line from whichever source is available
    String trxName = null;
    Properties ctx = null;
    int C_OrderLine_ID = 0;
    if (iLine != null) {
        trxName = iLine.get_TrxName();
        ctx = iLine.getCtx();
        C_OrderLine_ID = iLine.getC_OrderLine_ID();
    }
    if (sLine != null) {
        trxName = sLine.get_TrxName();
        ctx = sLine.getCtx();
        C_OrderLine_ID = sLine.getC_OrderLine_ID();
    }

    if (C_OrderLine_ID > 0) {
        return create(ctx, iLine, sLine, C_OrderLine_ID,
            dateTrx, qty, trxName);
    } else {
        // No direct order link - try to find via existing match records
        if (sLine != null && iLine != null) {
            MMatchPO[] matchpos = MMatchPO.get(ctx,
                sLine.getM_InOutLine_ID(), trxName);
            for (MMatchPO matchpo : matchpos) {
                C_OrderLine_ID = matchpo.getC_OrderLine_ID();
                MOrderLine orderLine = new MOrderLine(ctx,
                    C_OrderLine_ID, trxName);
                BigDecimal toInvoice = orderLine.getQtyOrdered()
                    .subtract(orderLine.getQtyInvoiced());
                if (toInvoice.signum() <= 0) continue;

                BigDecimal matchQty = qty;
                if (matchQty.compareTo(toInvoice) > 0)
                    matchQty = toInvoice;
                if (matchQty.signum() <= 0) continue;

                MMatchPO newMatchPO = create(ctx, iLine, sLine,
                    C_OrderLine_ID, dateTrx, matchQty, trxName);
                newMatchPO.save();
                qty = qty.subtract(matchQty);
                if (qty.signum() <= 0)
                    return newMatchPO;
            }
        }
        return null;
    }
}

The Internal Create Logic

The protected create() method handles the complex logic of finding and updating existing match records vs. creating new ones. It also auto-creates MMatchInv records when both receipt and invoice are present:

/**
 * Update or create MatchPO record (if needed, create MatchInv too).
 * Source: MMatchPO.java, line 367
 */
protected static MMatchPO create(Properties ctx, MInvoiceLine iLine,
        MInOutLine sLine, int C_OrderLine_ID, Timestamp dateTrx,
        BigDecimal qty, String trxName) {
    MMatchPO retValue = null;

    // Step 1: Try to find existing unmatched MatchPO records for this order line
    List<MMatchPO> matchPOList = MatchPOAutoMatch.getNotMatchedMatchPOList(
        ctx, C_OrderLine_ID, trxName);

    if (!matchPOList.isEmpty()) {
        for (MMatchPO mpo : matchPOList) {
            if (qty.compareTo(mpo.getQty()) >= 0) {
                // ... validate ASI compatibility ...
                // ... check invoice/receipt line compatibility ...

                // When both receipt and invoice are present, auto-create MMatchInv
                if ((iLine != null || mpo.getC_InvoiceLine_ID() > 0)
                    && (sLine != null || mpo.getM_InOutLine_ID() > 0))
                {
                    int M_InOutLine_ID = sLine != null
                        ? sLine.getM_InOutLine_ID()
                        : mpo.getM_InOutLine_ID();
                    int C_InvoiceLine_ID = iLine != null
                        ? iLine.getC_InvoiceLine_ID()
                        : mpo.getC_InvoiceLine_ID();

                    // Create MMatchInv if not already present
                    int cnt = DB.getSQLValue(mpo.get_TrxName(),
                        "SELECT Count(*) FROM M_MatchInv "
                        + "WHERE M_InOutLine_ID=" + M_InOutLine_ID
                        + " AND C_InvoiceLine_ID=" + C_InvoiceLine_ID);
                    if (cnt <= 0) {
                        MMatchInv matchInv = createMatchInv(mpo,
                            C_InvoiceLine_ID, M_InOutLine_ID,
                            mpo.getQty(), dateTrx, trxName);
                        mpo.setMatchInvCreated(matchInv);
                    }
                }

                // Update the existing match record
                if (iLine != null)
                    mpo.setC_InvoiceLine_ID(iLine);
                if (sLine != null) {
                    mpo.setM_InOutLine_ID(sLine.getM_InOutLine_ID());
                    if (!mpo.isPosted())
                        mpo.setDateAcct(sLine.getParent().getDateAcct());
                }
                mpo.save();

                qty = qty.subtract(mpo.getQty());
                if (qty.signum() <= 0) {
                    retValue = mpo;
                    break;
                }
            }
        }
    }

    // Step 2: Create new MatchPO if not fully matched from existing records
    if (retValue == null) {
        if (sLine != null && qty.signum() != 0) {
            retValue = new MMatchPO(sLine, dateTrx, qty);
            retValue.setC_OrderLine_ID(C_OrderLine_ID);
            if (iLine != null)
                retValue.setC_InvoiceLine_ID(iLine);
            retValue.save();
        }
    }

    return retValue;
}

Constructors

The MMatchPO constructors initialize records from either receipt lines or invoice lines:

/**
 * Shipment/Receipt Line Constructor.
 * Source: MMatchPO.java, line 836
 */
public MMatchPO(MInOutLine sLine, Timestamp dateTrx, BigDecimal qty) {
    this(sLine.getCtx(), 0, sLine.get_TrxName());
    setClientOrg(sLine);
    setM_InOutLine_ID(sLine.getM_InOutLine_ID());
    setC_OrderLine_ID(sLine.getC_OrderLine_ID());
    if (dateTrx != null)
        setDateTrx(dateTrx);
    setM_Product_ID(sLine.getM_Product_ID());
    setM_AttributeSetInstance_ID(sLine.getM_AttributeSetInstance_ID());
    setQty(qty);
    setProcessed(true);   // Automatically marked as processed
}

/**
 * Invoice Line Constructor.
 * Source: MMatchPO.java, line 856
 */
public MMatchPO(MInvoiceLine iLine, Timestamp dateTrx, BigDecimal qty) {
    this(iLine.getCtx(), 0, iLine.get_TrxName());
    setClientOrg(iLine);
    setC_InvoiceLine_ID(iLine);
    if (iLine.getC_OrderLine_ID() != 0)
        setC_OrderLine_ID(iLine.getC_OrderLine_ID());
    if (dateTrx != null)
        setDateTrx(dateTrx);
    setM_Product_ID(iLine.getM_Product_ID());
    setM_AttributeSetInstance_ID(iLine.getM_AttributeSetInstance_ID());
    setQty(qty);
    setProcessed(true);   // Automatically marked as processed
}

Notice that both constructors set Processed = true immediately. Match records are always processed upon creation — there is no draft/approve workflow for matching.

The getOrCreate Method

For scenarios where you want to either find an existing unposted match record or create a new one, the getOrCreate() method provides a convenient API:

/**
 * Get or create a MatchPO record for an order line and receipt line.
 * Source: MMatchPO.java, line 1470
 */
public static MMatchPO getOrCreate(int C_OrderLine_ID, BigDecimal qty,
        MInOutLine sLine, String trxName) {
    Query query = new Query(Env.getCtx(), MMatchPO.Table_Name,
        "C_OrderLine_ID=? AND Qty=? AND Posted IN (?,?) "
        + "AND M_InOutLine_ID IS NULL", trxName);
    MMatchPO matchPO = query.setParameters(
        C_OrderLine_ID, qty,
        Doc.STATUS_NotPosted, Doc.STATUS_Deferred).first();

    if (matchPO != null) {
        matchPO.setM_InOutLine_ID(sLine.getM_InOutLine_ID());
        return matchPO;
    } else {
        return new MMatchPO(sLine, null, qty);
    }
}

Auto-Creation of MMatchInv

When a MMatchPO record has both a receipt line and an invoice line, the system automatically creates a corresponding MMatchInv (Invoice-Receipt match) record via the createMatchInv() method:

/**
 * Create a MMatchInv record to link invoice line and receipt line.
 * Source: MMatchPO.java, line 706
 */
protected static MMatchInv createMatchInv(MMatchPO mpo,
        int C_InvoiceLine_ID, int M_InOutLine_ID,
        BigDecimal qty, Timestamp dateTrx, String trxName) {
    Savepoint savepoint = null;
    Trx trx = trxName != null ? Trx.get(trxName, false) : null;
    MMatchInv matchInv = null;

    try {
        savepoint = trx != null ? trx.getConnection().setSavepoint() : null;
        matchInv = new MMatchInv(mpo.getCtx(), 0, mpo.get_TrxName());
        matchInv.setC_InvoiceLine_ID(C_InvoiceLine_ID);
        matchInv.setM_Product_ID(mpo.getM_Product_ID());
        matchInv.setM_InOutLine_ID(M_InOutLine_ID);
        matchInv.setAD_Client_ID(mpo.getAD_Client_ID());
        matchInv.setAD_Org_ID(mpo.getAD_Org_ID());
        matchInv.setM_AttributeSetInstance_ID(
            mpo.getM_AttributeSetInstance_ID());
        matchInv.setQty(qty);
        matchInv.setDateTrx(dateTrx);
        matchInv.setProcessed(true);

        if (!matchInv.save()) {
            // Rollback to savepoint on failure
            if (savepoint != null)
                trx.getConnection().rollback(savepoint);
            return null;
        }
    } catch (Exception e) {
        // Handle exception with savepoint rollback
    }
    return matchInv;
}

This automatic MMatchInv creation is what completes the three-way match: PO line ↔ receipt line (via MMatchPO), receipt line ↔ invoice line (via MMatchInv). Together, these records close the procurement cycle for a given line item.

Purchase Price Variance (PPV)

The MMatchPO model also calculates Purchase Price Variance when both PO price and invoice price are available:

/**
 * Get the actual price from the linked invoice line.
 * Handles currency conversion if invoice and order currencies differ.
 * Source: MMatchPO.java, line 961
 */
public BigDecimal getInvoicePriceActual() {
    MInvoiceLine iLine = getInvoiceLine();
    MInvoice invoice = iLine.getParent();
    MOrder order = getOrderLine().getParent();

    BigDecimal priceActual = iLine.getPriceActual();
    int invoiceCurrency_ID = invoice.getC_Currency_ID();
    int orderCurrency_ID = order.getC_Currency_ID();

    if (invoiceCurrency_ID != orderCurrency_ID) {
        // Convert invoice price to order currency for comparison
        // ... currency conversion logic ...
    }
    return priceActual;
}

// In beforeSave() - PPV calculation:
BigDecimal poPrice = getOrderLine().getPriceActual();
BigDecimal invPrice = getInvoicePriceActual();
BigDecimal difference = poPrice.subtract(invPrice);
if (difference.signum() != 0) {
    difference = difference.multiply(getQty());
    setPriceMatchDifference(difference);  // Stored for PPV accounting

    // Validate against PriceMatchTolerance from BP Group
    MBPGroup group = MBPGroup.getOfBPartner(getCtx(),
        getOrderLine().getC_BPartner_ID());
    BigDecimal mt = group.getPriceMatchTolerance();
    // ... tolerance check to set IsApproved flag ...
}

Reversing Match Records

Match records can be reversed when a receipt or invoice is voided or reversed:

/**
 * Reverse this MatchPO document.
 * Source: MMatchPO.java, line 1334
 */
public boolean reverse(Timestamp reversalDate, boolean reverseMatchingOnly) {
    if (this.isProcessed() && this.getReversal_ID() == 0) {
        MMatchPO reversal = new MMatchPO(getCtx(), 0, get_TrxName());
        reversal.setC_InvoiceLine_ID(getC_InvoiceLine_ID());
        reversal.setM_InOutLine_ID(getM_InOutLine_ID());

        if (getC_OrderLine_ID() != 0)
            reversal.setC_OrderLine_ID(getC_OrderLine_ID());
        else {
            MInOutLine inoutLine = new MInOutLine(getCtx(),
                getM_InOutLine_ID(), get_TrxName());
            reversal.setC_OrderLine_ID(inoutLine.getC_OrderLine_ID());
        }

        reversal.setM_Product_ID(getM_Product_ID());
        reversal.setM_AttributeSetInstance_ID(
            getM_AttributeSetInstance_ID());
        reversal.setAD_Org_ID(this.getAD_Org_ID());
        reversal.setQty(this.getQty().negate());    // Negate the quantity
        reversal.setDateTrx(reversalDate);
        reversal.setDateAcct(reversalDate);
        // ... set reversal links ...
    }
    return true;
}

MMatchPO Key Methods Reference

Method Description
get(ctx, C_OrderLine_ID, C_InvoiceLine_ID, trxName) Find matches by order line + invoice line combination.
get(ctx, M_InOutLine_ID, trxName) Find all matches for a specific receipt line.
getInOut(ctx, M_InOut_ID, trxName) Find all matches for all lines of a receipt.
getInvoice(ctx, C_Invoice_ID, trxName) Find all matches for all lines of an invoice.
getOrderLine(ctx, C_OrderLine_ID, trxName) Find all matches for a specific PO line.
create(iLine, sLine, dateTrx, qty) Create or update a match record. Auto-creates MMatchInv when appropriate.
getOrCreate(C_OrderLine_ID, qty, sLine, trxName) Find an existing unposted match or create a new one.
getInvoicePriceActual() Get the invoice price (with currency conversion if needed) for PPV calculation.
reverse(reversalDate) Create a reversal match record with negated quantity.
isReversal() Check if this record is itself a reversal.

7. MStorageOnHand — Inventory Updates

The MStorageOnHand model represents the M_StorageOnHand table, which stores current on-hand inventory quantities at the locator level. Each record uniquely identifies a quantity of a specific product, with a specific attribute set instance (lot/serial), at a specific locator, with a specific material policy date. When a receipt is completed, MStorageOnHand.add() is called to increase the on-hand quantity.

The add() Method — Core Inventory Update

The add() method is the workhorse of inventory updates. It is called by MInOut.completeIt() and many other processes:

/**
 * Update On Hand Storage (basic signature with warehouse).
 * Source: MStorageOnHand.java, line 769
 */
public static boolean add(Properties ctx,
    int M_Warehouse_ID, int M_Locator_ID,
    int M_Product_ID, int M_AttributeSetInstance_ID,
    BigDecimal diffQtyOnHand, String trxName)
{
    return add(ctx, M_Warehouse_ID, M_Locator_ID,
        M_Product_ID, M_AttributeSetInstance_ID,
        diffQtyOnHand, null, trxName);
}

/**
 * Update On Hand Storage with material policy date.
 * Source: MStorageOnHand.java, line 791
 */
public static boolean add(Properties ctx,
    int M_Warehouse_ID, int M_Locator_ID,
    int M_Product_ID, int M_AttributeSetInstance_ID,
    BigDecimal diffQtyOnHand, Timestamp dateMPolicy,
    String trxName)
{
    return add(ctx, M_Locator_ID, M_Product_ID,
        M_AttributeSetInstance_ID,
        diffQtyOnHand, dateMPolicy, null, trxName);
}

/**
 * Full signature with material policy date and last inventory date.
 * Source: MStorageOnHand.java, line 817
 */
public static boolean add(Properties ctx, int M_Locator_ID,
    int M_Product_ID, int M_AttributeSetInstance_ID,
    BigDecimal diffQtyOnHand, Timestamp dateMPolicy,
    Timestamp dateLastInventory, String trxName)
{
    if (diffQtyOnHand == null || diffQtyOnHand.signum() == 0)
        return true;    // Nothing to do

    if (dateMPolicy != null)
        dateMPolicy = Util.removeTime(dateMPolicy);  // Strip time component

    // Get or create the storage record
    MStorageOnHand storage = getCreate(ctx, M_Locator_ID,
        M_Product_ID, M_AttributeSetInstance_ID,
        dateMPolicy, trxName);

    // Add the difference to existing quantity
    BigDecimal oldQty = storage.getQtyOnHand();
    BigDecimal newQty = oldQty.add(diffQtyOnHand);

    // Check for negative inventory (if disallowed by warehouse config)
    // ... negative inventory validation ...

    storage.setQtyOnHand(newQty);
    if (dateLastInventory != null)
        storage.setDateLastInventory(dateLastInventory);
    storage.save(trxName);

    return true;
}

Key behaviors of the add() method:

  • It creates the storage record if it does not exist (via getCreate()).
  • It handles both positive (receipt) and negative (shipment) quantity changes.
  • It strips the time component from the material policy date to ensure consistent grouping.
  • It respects the warehouse’s “Disallow Negative Inventory” setting.
  • It operates within the provided transaction (trxName) for atomic commits.

The getCreate() Method

The getCreate() method either finds an existing storage record or creates a new one. The combination of locator + product + ASI + material policy date forms the unique key:

/**
 * Create or Get On Hand Storage.
 * Source: MStorageOnHand.java, line 721
 */
public static MStorageOnHand getCreate(Properties ctx,
    int M_Locator_ID, int M_Product_ID,
    int M_AttributeSetInstance_ID, Timestamp dateMPolicy,
    String trxName, boolean forUpdate, int timeout)
{
    if (M_Locator_ID == 0)
        throw new IllegalArgumentException("M_Locator_ID=0");
    if (M_Product_ID == 0)
        throw new IllegalArgumentException("M_Product_ID=0");
    if (dateMPolicy != null)
        dateMPolicy = Util.removeTime(dateMPolicy);

    // Try to get existing record
    MStorageOnHand retValue = get(ctx, M_Locator_ID, M_Product_ID,
        M_AttributeSetInstance_ID, dateMPolicy, trxName);

    if (retValue != null) {
        if (forUpdate)
            DB.getDatabase().forUpdate(retValue, timeout);
        return retValue;
    }

    // Create new record
    retValue = new MStorageOnHand(ctx, 0, trxName);
    retValue.setM_Locator_ID(M_Locator_ID);
    retValue.setM_Product_ID(M_Product_ID);
    retValue.setM_AttributeSetInstance_ID(M_AttributeSetInstance_ID);
    retValue.setDateMaterialPolicy(dateMPolicy);
    retValue.setQtyOnHand(Env.ZERO);
    retValue.save(trxName);
    return retValue;
}

Query Methods

MStorageOnHand provides numerous query methods for checking inventory levels:

/**
 * Get total on-hand quantity for a product in a warehouse.
 * Source: MStorageOnHand.java, line 1070
 */
public static BigDecimal getQtyOnHand(int M_Product_ID,
    int M_Warehouse_ID, int M_AttributeSetInstance_ID,
    String trxName)
{
    StringBuilder sql = new StringBuilder();
    sql.append("SELECT SUM(QtyOnHand) FROM M_StorageOnHand oh")
       .append(" JOIN M_Locator loc ON (oh.M_Locator_ID=loc.M_Locator_ID)")
       .append(" WHERE oh.M_Product_ID=?")
       .append(" AND loc.M_Warehouse_ID=?");
    // ... optional ASI filter ...
    return DB.getSQLValueBD(trxName, sql.toString(), params);
}

/**
 * Get on-hand quantity at a specific locator.
 * Source: MStorageOnHand.java, line 1244
 */
public static BigDecimal getQtyOnHandForLocator(int M_Product_ID,
    int M_Locator_ID, int M_AttributeSetInstance_ID,
    String trxName)
{
    StringBuilder sql = new StringBuilder();
    sql.append("SELECT SUM(oh.QtyOnHand) FROM M_StorageOnHand oh")
       .append(" WHERE oh.M_Product_ID=?")
       .append(" AND oh.M_Locator_ID=?");
    // ... optional ASI filter ...
    return DB.getSQLValueBD(trxName, sql.toString(), params);
}

/**
 * Get all storage records for a warehouse, ordered by material policy.
 * Used for FIFO/LIFO stock allocation.
 * Source: MStorageOnHand.java, line 398
 */
public static MStorageOnHand[] getWarehouse(Properties ctx,
    int M_Warehouse_ID, int M_Product_ID,
    int M_AttributeSetInstance_ID, Timestamp minGuaranteeDate,
    boolean FiFo, boolean positiveOnly, int M_Locator_ID,
    String trxName, boolean forUpdate, int timeout)
{
    // Returns storage records ordered by DateMaterialPolicy (ASC for FIFO,
    // DESC for LIFO), filtered by warehouse/locator/product/ASI
    // ...
}

Specialized Query Methods

Method Description
getQtyOnHand(product, warehouse, ASI, trx) Total on-hand for product in warehouse (all locators).
getQtyOnHandForLocator(product, locator, ASI, trx) On-hand at a specific locator.
getQtyOnHandForReservation(product, warehouse, ASI, trx) On-hand excluding locators not available for reservation.
getQtyOnHandForShipping(product, warehouse, ASI, trx) On-hand excluding locators not available for shipping.
getWarehouse(ctx, warehouse, product, ...) All storage records for FIFO/LIFO allocation.
getWarehouseNegative(ctx, warehouse, product, ...) Storage records with negative on-hand (for correction).
getAllWithASI(ctx, product, locator, FiFo, trx) All storage with specific ASI (non-zero) and QtyOnHand != 0.
get(ctx, locator, product, ASI, dateMPolicy, trx) Get a specific storage record (exact match).
getCreate(ctx, locator, product, ASI, dateMPolicy, trx) Get or create a storage record.
add(ctx, locator, product, ASI, qty, dateMPolicy, trx) Add quantity to storage (positive or negative).

How the Receipt Updates Inventory: End-to-End

To summarize the inventory update path when a procurement receipt is completed:

// 1. User creates receipt from PO and completes it
MInOut receipt = new MInOut(purchaseOrder, 0, movementDate);
// ... add lines ...
receipt.setDocAction(DocAction.ACTION_Complete);
receipt.processIt(DocAction.ACTION_Complete);

// 2. Inside completeIt(), for each line with a stocked product:
//    a. checkMaterialPolicy() determines the dateMPolicy
//    b. MStorageOnHand.add() is called:

MStorageOnHand.add(ctx,
    sLine.getM_Locator_ID(),     // e.g., Warehouse A, Shelf 1
    sLine.getM_Product_ID(),     // e.g., Product "Widget"
    sLine.getM_AttributeSetInstance_ID(), // e.g., Lot #2024-001
    Qty,                          // e.g., 100 (positive for V+)
    dateMPolicy,                  // e.g., 2024-01-15
    trxName);

// 3. Inside add():
//    a. getCreate() finds or creates M_StorageOnHand record for
//       (Locator, Product, ASI, DateMPolicy) combination
//    b. Existing QtyOnHand (e.g., 50) + diffQtyOnHand (100) = 150
//    c. Record saved with new QtyOnHand = 150

// 4. MTransaction record is also created to log the movement

8. Complete Code Examples

This section provides practical, end-to-end code examples for common procurement receipt operations.

Example 1: Creating a Receipt from a Purchase Order

/**
 * Create a Material Receipt from a completed Purchase Order.
 * Receives the full ordered quantity for all lines.
 */
public MInOut createReceiptFromPO(MOrder purchaseOrder, String trxName) {
    // Validate: must be a purchase order
    if (purchaseOrder.isSOTrx())
        throw new AdempiereException("Cannot create receipt from Sales Order");

    // Create the receipt header from the PO
    // Passing 0 for doc type -> auto-resolves from PO's doc type
    Timestamp today = new Timestamp(System.currentTimeMillis());
    MInOut receipt = new MInOut(purchaseOrder, 0, today);
    receipt.setDocStatus(DocAction.STATUS_Drafted);
    receipt.setDocAction(DocAction.ACTION_Complete);
    receipt.saveEx(trxName);

    // Create receipt lines for each PO line
    MOrderLine[] orderLines = purchaseOrder.getLines(true, null);
    for (MOrderLine oLine : orderLines) {
        // Calculate remaining quantity to receive
        BigDecimal qtyToReceive = oLine.getQtyOrdered()
            .subtract(oLine.getQtyDelivered());

        if (qtyToReceive.signum() <= 0)
            continue;  // Already fully received

        // Create receipt line
        MInOutLine receiptLine = new MInOutLine(receipt);
        receiptLine.setOrderLine(oLine, 0, qtyToReceive);  // 0 = auto-find locator
        receiptLine.setQty(qtyToReceive);
        receiptLine.saveEx(trxName);
    }

    // Complete the receipt
    if (!receipt.processIt(DocAction.ACTION_Complete))
        throw new AdempiereException("Failed to complete receipt: "
            + receipt.getProcessMsg());
    receipt.saveEx(trxName);

    // At this point:
    // - MStorageOnHand records have been updated (inventory increased)
    // - MMatchPO records have been created (PO-Receipt matching)
    // - MTransaction records have been created (audit trail)
    // - PO line QtyReserved has been decreased
    // - If invoice exists, MMatchInv may have been auto-created

    return receipt;
}

Example 2: Querying Match Records

/**
 * Query and analyze match records for a completed receipt.
 */
public void analyzeReceiptMatching(int M_InOut_ID, String trxName) {
    Properties ctx = Env.getCtx();

    // Get all MMatchPO records for this receipt
    MMatchPO[] matches = MMatchPO.getInOut(ctx, M_InOut_ID, trxName);
    System.out.println("Total match records: " + matches.length);

    for (MMatchPO match : matches) {
        System.out.println("--- Match Record ---");
        System.out.println("  M_MatchPO_ID: " + match.getM_MatchPO_ID());
        System.out.println("  C_OrderLine_ID: " + match.getC_OrderLine_ID());
        System.out.println("  M_InOutLine_ID: " + match.getM_InOutLine_ID());
        System.out.println("  C_InvoiceLine_ID: " + match.getC_InvoiceLine_ID());
        System.out.println("  Matched Qty: " + match.getQty());
        System.out.println("  Product: " + match.getM_Product_ID());
        System.out.println("  Date: " + match.getDateTrx());
        System.out.println("  Posted: " + match.isPosted());
        System.out.println("  Is Reversal: "
            + (match.getReversal_ID() > 0));

        // Check if invoice is also matched (three-way match)
        if (match.getC_InvoiceLine_ID() > 0) {
            System.out.println("  THREE-WAY MATCH COMPLETE");
            // Check for price variance
            BigDecimal ppv = match.getPriceMatchDifference();
            if (ppv != null && ppv.signum() != 0) {
                System.out.println("  PPV Amount: " + ppv);
                System.out.println("  Approved: " + match.isApproved());
            }
        } else {
            System.out.println("  TWO-WAY MATCH (PO-Receipt only)");
        }
    }

    // Find unmatched receipts using the matching SQL helpers
    List<MInOut.MatchingRecord> unmatched =
        MInOut.getNotFullyMatchedToInvoice(
            0, 0, 0, null, null, trxName);
    System.out.println("\nReceipts not fully matched to invoices: "
        + unmatched.size());

    for (MInOut.MatchingRecord rec : unmatched) {
        System.out.println("  Doc: " + rec.documentNo()
            + " | Line: " + rec.line()
            + " | Product: " + rec.productName()
            + " | Qty: " + rec.movementQty()
            + " | Matched: " + rec.matchedQty());
    }
}

Example 3: Verifying Inventory After Receipt

/**
 * Verify inventory levels after completing a receipt.
 */
public void verifyInventoryAfterReceipt(MInOut receipt, String trxName) {
    Properties ctx = receipt.getCtx();
    int warehouseId = receipt.getM_Warehouse_ID();

    MInOutLine[] lines = receipt.getLines(true);
    for (MInOutLine line : lines) {
        if (line.getM_Product_ID() == 0)
            continue;  // Skip charge lines

        MProduct product = line.getProduct();
        System.out.println("Product: " + product.getValue()
            + " - " + product.getName());
        System.out.println("  Received Qty: " + line.getMovementQty());
        System.out.println("  Locator: " + line.getM_Locator_ID());
        System.out.println("  ASI: "
            + line.getM_AttributeSetInstance_ID());

        // Check on-hand at the warehouse level
        BigDecimal warehouseQty = MStorageOnHand.getQtyOnHand(
            line.getM_Product_ID(), warehouseId,
            line.getM_AttributeSetInstance_ID(), trxName);
        System.out.println("  Warehouse On-Hand: " + warehouseQty);

        // Check on-hand at the specific locator
        BigDecimal locatorQty = MStorageOnHand.getQtyOnHandForLocator(
            line.getM_Product_ID(), line.getM_Locator_ID(),
            line.getM_AttributeSetInstance_ID(), trxName);
        System.out.println("  Locator On-Hand: " + locatorQty);

        // Get all storage records for this product in the warehouse
        // (to see distribution across locators and ASIs)
        MStorageOnHand[] storages = MStorageOnHand.getWarehouse(
            ctx, warehouseId,
            line.getM_Product_ID(), 0,  // 0 = all ASIs
            null, true, false, 0, trxName);
        System.out.println("  Storage records in warehouse: "
            + storages.length);
        for (MStorageOnHand storage : storages) {
            System.out.println("    Locator=" + storage.getM_Locator_ID()
                + " ASI=" + storage.getM_AttributeSetInstance_ID()
                + " Qty=" + storage.getQtyOnHand()
                + " DatePolicy=" + storage.getDateMaterialPolicy());
        }

        // Also check reservation quantities
        BigDecimal reservationQty =
            MStorageOnHand.getQtyOnHandForReservation(
                line.getM_Product_ID(), warehouseId,
                line.getM_AttributeSetInstance_ID(), trxName);
        System.out.println("  Available for Reservation: "
            + reservationQty);

        BigDecimal shippingQty =
            MStorageOnHand.getQtyOnHandForShipping(
                line.getM_Product_ID(), warehouseId,
                line.getM_AttributeSetInstance_ID(), trxName);
        System.out.println("  Available for Shipping: " + shippingQty);
    }
}

Example 4: Partial Receipt Handling

/**
 * Create a partial receipt - receive only some lines or partial quantities.
 * This is common when a vendor ships in multiple batches.
 */
public MInOut createPartialReceipt(MOrder purchaseOrder,
        Map<Integer, BigDecimal> lineQtyMap, String trxName) {
    // lineQtyMap: C_OrderLine_ID -> quantity to receive in this shipment

    Timestamp today = new Timestamp(System.currentTimeMillis());
    MInOut receipt = new MInOut(purchaseOrder, 0, today);
    receipt.setDescription("Partial Receipt");
    receipt.saveEx(trxName);

    int lineCount = 0;
    MOrderLine[] orderLines = purchaseOrder.getLines(true, null);

    for (MOrderLine oLine : orderLines) {
        BigDecimal qtyToReceive = lineQtyMap.get(oLine.getC_OrderLine_ID());
        if (qtyToReceive == null || qtyToReceive.signum() <= 0)
            continue;  // Skip lines not in this partial receipt

        // Validate: don't receive more than remaining
        BigDecimal qtyRemaining = oLine.getQtyOrdered()
            .subtract(oLine.getQtyDelivered());
        if (qtyToReceive.compareTo(qtyRemaining) > 0)
            qtyToReceive = qtyRemaining;

        if (qtyToReceive.signum() <= 0)
            continue;

        MInOutLine receiptLine = new MInOutLine(receipt);
        receiptLine.setOrderLine(oLine, 0, qtyToReceive);
        receiptLine.setQty(qtyToReceive);
        receiptLine.saveEx(trxName);
        lineCount++;
    }

    if (lineCount == 0)
        throw new AdempiereException("No lines to receive");

    // Complete
    if (!receipt.processIt(DocAction.ACTION_Complete))
        throw new AdempiereException("Failed: " + receipt.getProcessMsg());
    receipt.saveEx(trxName);

    // After partial receipt:
    // - PO remains open (not fully delivered)
    // - MMatchPO records created for received quantities only
    // - Next receipt can receive the remaining quantities
    // - User can create additional receipts for the same PO

    // Verify PO line status
    for (MOrderLine oLine : orderLines) {
        oLine.load(trxName);  // Refresh from DB
        BigDecimal remaining = oLine.getQtyOrdered()
            .subtract(oLine.getQtyDelivered());
        System.out.println("PO Line " + oLine.getLine()
            + ": Ordered=" + oLine.getQtyOrdered()
            + ", Delivered=" + oLine.getQtyDelivered()
            + ", Remaining=" + remaining);
    }

    return receipt;
}

/**
 * Example usage of partial receipt:
 */
public void partialReceiptExample(String trxName) {
    Properties ctx = Env.getCtx();
    MOrder po = new MOrder(ctx, 1000123, trxName);  // Example PO

    // PO has 3 lines:
    //   Line 10: Widget A, Qty 100
    //   Line 20: Widget B, Qty 200
    //   Line 30: Widget C, Qty 50

    // First shipment: receive line 10 (full) and line 20 (partial)
    Map<Integer, BigDecimal> batch1 = new HashMap<>();
    batch1.put(1000456, new BigDecimal("100"));  // Line 10: full 100
    batch1.put(1000457, new BigDecimal("120"));  // Line 20: partial 120/200
    MInOut receipt1 = createPartialReceipt(po, batch1, trxName);

    // Second shipment: receive remaining line 20 and all of line 30
    Map<Integer, BigDecimal> batch2 = new HashMap<>();
    batch2.put(1000457, new BigDecimal("80"));   // Line 20: remaining 80
    batch2.put(1000458, new BigDecimal("50"));   // Line 30: full 50
    MInOut receipt2 = createPartialReceipt(po, batch2, trxName);

    // After both receipts:
    // - All PO lines are fully delivered
    // - Two separate MMatchPO sets exist (one per receipt)
    // - Inventory reflects total: Widget A=100, Widget B=200, Widget C=50
}

9. The Three-Way Matching Lifecycle

Understanding the complete lifecycle of procurement matching helps connect all the pieces covered in this lesson. Here is the end-to-end flow from PO creation through full three-way matching:

Step 1: Purchase Order Creation

// When a PO is completed:
// - MStorageReservation records increase (QtyOrdered)
// - No inventory changes yet
// - No match records yet

Step 2: Material Receipt (V+)

// When the receipt is completed:
// 1. MStorageOnHand.add() increases on-hand inventory
// 2. MStorageReservation decreases (QtyOrdered reduced)
// 3. MMatchPO is created: links M_InOutLine_ID to C_OrderLine_ID
// 4. MTransaction is created (audit trail)
// 5. If invoice already exists for this PO line:
//    - MMatchInv is auto-created (receipt-invoice link)
//    - PPV is calculated on MMatchPO

Step 3: Vendor Invoice

// When the vendor invoice is completed:
// 1. MMatchPO is created or updated:
//    - If receipt already matched to this PO line, the existing
//      MMatchPO is updated with C_InvoiceLine_ID
//    - Otherwise, a new MMatchPO is created
// 2. MMatchInv is auto-created if both receipt and invoice are matched
// 3. PPV is calculated:
//    - PO Price vs. Invoice Price
//    - PriceMatchDifference stored on MMatchPO
//    - IsApproved set based on BP Group tolerance

Matching State Diagram

State MMatchPO Fields MMatchInv Description
PO Only (no receipt, no invoice) C_OrderLine_ID only None PO completed, waiting for goods
Two-way: PO + Receipt C_OrderLine_ID + M_InOutLine_ID None Goods received, waiting for invoice
Two-way: PO + Invoice C_OrderLine_ID + C_InvoiceLine_ID None Invoice received before goods (prepay)
Three-way: PO + Receipt + Invoice C_OrderLine_ID + M_InOutLine_ID + C_InvoiceLine_ID M_InOutLine_ID + C_InvoiceLine_ID Fully matched – PPV calculated

Sequence Flexibility

iDempiere supports matching regardless of the order in which documents arrive:

  • Normal flow: PO -> Receipt -> Invoice. The receipt creates MMatchPO with PO link. The invoice updates MMatchPO with invoice link and auto-creates MMatchInv.
  • Invoice before receipt: PO -> Invoice -> Receipt. The invoice creates MMatchPO with PO + invoice links. The receipt updates MMatchPO with receipt link and auto-creates MMatchInv.
  • Receipt without PO: Receipt (no PO reference) -> Invoice (with PO reference). The system finds the PO link through the invoice and creates matches accordingly.

10. Summary

In this lesson, you have studied the complete Material Receipt and Goods Matching subsystem of iDempiere’s procurement module. Here are the key takeaways:

Core Classes and Their Responsibilities

Class Table Procurement Role
MInOut M_InOut Receipt header — movement type V+, DocBaseType MMR. Implements DocAction for document workflow. The completeIt() method orchestrates inventory updates, reservation adjustments, and matching.
MInOutLine M_InOutLine Receipt line — links to PO line via C_OrderLine_ID. Manages product, quantity, locator, and ASI. Auto-generates lots for configured products.
MMatchPO M_MatchPO PO-Receipt match — links receipt lines to PO lines (and optionally invoice lines). Calculates PPV. Auto-creates MMatchInv for three-way matching.
MStorageOnHand M_StorageOnHand On-hand inventory — tracks quantity by locator + product + ASI + material policy date. The add() method is the single entry point for all inventory changes.

Key Architectural Patterns

  • Movement type derivation: The movement type (V+, V-, C+, C-) is always derived from DocBaseType + isSOTrx, never set directly. This ensures consistency between document type and inventory behavior.
  • PO delivered qty via matching: For purchase order lines with products, the delivered quantity is updated through the MMatchPO matching process, not directly in completeIt(). This is explicitly noted in the source code: “PO is done by Matching.”
  • Automatic match cascading: When a MMatchPO record has both a receipt line and an invoice line, MMatchInv is automatically created via createMatchInv(). This eliminates the need for manual intervention in the three-way matching process.
  • Transaction safety: All inventory and matching operations run within the same database transaction. If any step fails, the entire receipt completion is rolled back. The createMatchInv() method uses savepoints for partial rollback within the transaction.
  • UOM precision enforcement: Both setQtyEntered() and setMovementQty() enforce precision based on the UOM and product configuration, preventing rounding-related discrepancies.

Common Implementation Scenarios

  • Full receipt from PO: Use the MInOut(MOrder, ...) constructor. Create MInOutLine with setOrderLine() for each PO line. Complete the document.
  • Partial receipt: Create lines only for received items/quantities. The PO remains open for subsequent receipts. Each receipt creates its own MMatchPO records.
  • Receipt verification: Use MMatchPO.getInOut() to check matching status. Use MStorageOnHand.getQtyOnHand() to verify inventory levels. Use MInOut.getNotFullyMatchedToInvoice() to find receipts awaiting invoice matching.
  • Lot tracking: Configure IsAutoGenerateLot on the product’s attribute set. The system automatically assigns lot numbers during receipt (not returns, not reversals, not sales).

Database Tables Involved

Table Purpose Key Columns
M_InOut Receipt/Shipment header M_InOut_ID, C_DocType_ID, MovementType, C_BPartner_ID, M_Warehouse_ID, MovementDate, DocStatus
M_InOutLine Receipt/Shipment lines M_InOutLine_ID, M_InOut_ID, C_OrderLine_ID, M_Product_ID, M_Locator_ID, MovementQty, M_AttributeSetInstance_ID
M_MatchPO PO-Receipt matching M_MatchPO_ID, C_OrderLine_ID, M_InOutLine_ID, C_InvoiceLine_ID, M_Product_ID, Qty, PriceMatchDifference
M_MatchInv Receipt-Invoice matching M_MatchInv_ID, M_InOutLine_ID, C_InvoiceLine_ID, M_Product_ID, Qty
M_StorageOnHand On-hand inventory M_Locator_ID, M_Product_ID, M_AttributeSetInstance_ID, QtyOnHand, DateMaterialPolicy
M_Transaction Material transaction log M_Transaction_ID, M_InOutLine_ID, MovementType, MovementQty, MovementDate
M_StorageReservation Reserved/Ordered quantities M_Warehouse_ID, M_Product_ID, M_AttributeSetInstance_ID, Qty, IsSOTrx

In the next lesson, you will study the Vendor Invoice processing and how it completes the procurement cycle by creating MMatchPO records from the invoice side, handling purchase price variance accounting, and integrating with the financial module for accounts payable.

You Missed