Purchase Contracts & Blanket Orders

Level: Advanced Module: Procurement 35 min read Lesson 34 of 55

Overview

  • What you’ll learn:
    • How iDempiere implements purchase contracts and blanket orders using linked document fields (Link_Order_ID and Link_OrderLine_ID)
    • The complete counter document mechanism in MOrder.createCounterDoc() for inter-organization transactions
    • How drop-ship flows work end-to-end, from MOrder’s IsDropShip flag through MInOut.createDropShipment() automatic shipment generation
    • How to model blanket purchase orders as master contracts with release orders linked via Link_OrderLine_ID
    • How voidIt() safely cleans up linked references to prevent orphaned cross-document pointers
    • Contract pricing enforcement, expiration monitoring, and release order workflow patterns
  • Prerequisites: Lesson 2 — Purchase Orders (MOrder, MOrderLine, and document processing fundamentals)
  • Estimated reading time: 25 minutes

Introduction: Contract-Based Procurement

In real-world procurement, organizations rarely purchase goods one order at a time. Instead, they negotiate contracts with vendors that establish pricing, quantities, and delivery schedules over extended periods. These contracts take many forms — blanket purchase orders, framework agreements, scheduled releases, and drop-ship arrangements — all aimed at reducing procurement overhead while securing favorable terms.

iDempiere handles contract-based procurement through a mechanism of linked documents rather than a separate contract entity. The core idea is elegant: a purchase order can reference another order through the Link_Order_ID field at the header level and Link_OrderLine_ID at the line level. This creates parent-child relationships between orders that enable blanket order patterns, counter documents for inter-organization trading, and drop-ship flows.

Types of Contract-Based Procurement

Before diving into the code, it helps to understand the procurement patterns that iDempiere’s linked document architecture supports:

Pattern Description iDempiere Mechanism
Blanket Purchase Order A master order committing to a total quantity over a period, with individual release orders drawn against it Link_OrderLine_ID on release order lines pointing back to blanket order lines
Counter Documents Automatic creation of a corresponding PO/SO in a partner organization when an SO/PO is completed createCounterDoc() in MOrder using Ref_Order_ID and org-to-BPartner mapping
Drop-Ship Orders Vendor ships directly to the customer, bypassing the purchasing organization’s warehouse IsDropShip flag plus DropShip_BPartner_ID, DropShip_Location_ID, DropShip_User_ID fields, with MInOut.createDropShipment()
Framework Agreement A negotiated price list effective for a time period, referenced by individual purchase orders Price list versioning with M_PriceList_Version validity dates plus custom validation

This lesson focuses on the first three patterns, with deep dives into the actual source code that powers them.

Link Fields Deep Dive

The foundation of iDempiere’s contract and linked-order architecture rests on two key columns defined in the data dictionary and exposed through the model interfaces. Understanding how these fields are defined, stored, and used is essential for working with any contract pattern.

Link_Order_ID on C_Order

The Link_Order_ID column on the C_Order table creates a header-level link between two orders. Its interface definition in I_C_Order declares the column name constant and the accessor methods:

// From org.compiere.model.I_C_Order

/** Column name Link_Order_ID */
public static final String COLUMNNAME_Link_Order_ID = "Link_Order_ID";

/** Set Linked Order.
  * This field links a sales order to the purchase order that is generated from it.
  */
public void setLink_Order_ID (int Link_Order_ID);

/** Get Linked Order.
  * This field links a sales order to the purchase order that is generated from it.
  */
public int getLink_Order_ID();

@Deprecated(since="13") // use better methods with cache
public org.compiere.model.I_C_Order getLink_Order() throws RuntimeException;

The generated model class X_C_Order implements these methods with a critical detail — the field is stored via set_ValueNoCheck, meaning it cannot be changed through normal UI editing once set:

// From org.compiere.model.X_C_Order

public void setLink_Order_ID (int Link_Order_ID)
{
    if (Link_Order_ID < 1)
        set_ValueNoCheck (COLUMNNAME_Link_Order_ID, null);
    else
        set_ValueNoCheck (COLUMNNAME_Link_Order_ID, Integer.valueOf(Link_Order_ID));
}

public int getLink_Order_ID()
{
    Integer ii = (Integer)get_Value(COLUMNNAME_Link_Order_ID);
    if (ii == null)
         return 0;
    return ii.intValue();
}

The use of set_ValueNoCheck is significant. In iDempiere's persistence layer, this method bypasses the normal value change tracking and validation that set_Value performs. The field is effectively read-only from the UI perspective — it can only be set programmatically by the system when creating linked documents.

Link_OrderLine_ID on C_OrderLine

At the line level, Link_OrderLine_ID on C_OrderLine connects individual order lines between linked orders. This is the critical field for blanket order patterns, where each release order line needs to reference the specific blanket order line it draws against:

// From org.compiere.model.I_C_OrderLine

/** Column name Link_OrderLine_ID */
public static final String COLUMNNAME_Link_OrderLine_ID = "Link_OrderLine_ID";

/** Set Linked Order Line.
  * This field links a sales order line to the purchase order line
  * that is generated from it.
  */
public void setLink_OrderLine_ID (int Link_OrderLine_ID);

/** Get Linked Order Line.
  * This field links a sales order line to the purchase order line
  * that is generated from it.
  */
public int getLink_OrderLine_ID();

@Deprecated(since="13") // use better methods with cache
public org.compiere.model.I_C_OrderLine getLink_OrderLine() throws RuntimeException;

The generated implementation in X_C_OrderLine follows the same set_ValueNoCheck pattern:

// From org.compiere.model.X_C_OrderLine

public void setLink_OrderLine_ID (int Link_OrderLine_ID)
{
    if (Link_OrderLine_ID < 1)
        set_ValueNoCheck (COLUMNNAME_Link_OrderLine_ID, null);
    else
        set_ValueNoCheck (COLUMNNAME_Link_OrderLine_ID, Integer.valueOf(Link_OrderLine_ID));
}

public int getLink_OrderLine_ID()
{
    Integer ii = (Integer)get_Value(COLUMNNAME_Link_OrderLine_ID);
    if (ii == null)
         return 0;
    return ii.intValue();
}

Link Fields vs. Ref Fields

It is important to distinguish between the Link fields and the Ref (Reference) fields, as they serve different purposes:

Field Purpose Set By Bidirectional?
Link_Order_ID Links SO to its generated PO (or vice versa) for operational tracking, drop-ship, and blanket orders System (programmatic) No — typically set on one side only
Link_OrderLine_ID Links individual SO line to its generated PO line for drop-ship line resolution and blanket order consumption System (programmatic) No — typically set on one side only
Ref_Order_ID Cross-references counter documents in inter-org transactions createCounterDoc() Yes — set on both the original and the counter document
Ref_OrderLine_ID Cross-references counter document lines copyLinesFrom() with counter=true Yes — set on both sides

Key Fields Reference

Table Column Type References Write Method
C_Order Link_Order_ID Integer (FK) C_Order.C_Order_ID set_ValueNoCheck
C_Order Ref_Order_ID Integer (FK) C_Order.C_Order_ID set_Value
C_OrderLine Link_OrderLine_ID Integer (FK) C_OrderLine.C_OrderLine_ID set_ValueNoCheck
C_OrderLine Ref_OrderLine_ID Integer (FK) C_OrderLine.C_OrderLine_ID set_Value

Counter Documents: createCounterDoc()

Counter documents are iDempiere's mechanism for inter-organization transactions. When Organization A sells something to Organization B (where both exist within the same iDempiere client), completing the Sales Order in Org A can automatically generate a Purchase Order in Org B. This is the counter document pattern, and it is driven by the createCounterDoc() method in MOrder.

When Counter Documents Are Generated

Counter document creation is triggered during completeIt() in MOrder. After the standard completion steps (approval, reservation, shipment/invoice generation), the system calls createCounterDoc():

// From MOrder.completeIt() — lines 2170-2174

// Counter Documents
// move by IDEMPIERE-2216
MOrder counter = createCounterDoc();
if (counter != null)
    info.append(" - @CounterDoc@: @Order@=").append(counter.getDocumentNo());

Prerequisites for Counter Document Creation

The createCounterDoc() method performs several prerequisite checks before creating the counter order. All of these must be satisfied or the method returns null silently:

  1. Not itself a counter document: If getRef_Order_ID() != 0, this order is already a counter doc and should not spawn another one.
  2. Organization linked to BPartner: The current organization must have a linked business partner (the organization is represented as a vendor/customer in the other org's books).
  3. Business Partner linked to Organization: The business partner on the order must have an AD_OrgBP_ID set, identifying which organization they represent.
  4. Valid counter document type: Either a direct MDocTypeCounter mapping must exist, or an indirect counter document type must be resolvable.

Complete createCounterDoc() Walkthrough

// From org.compiere.model.MOrder — createCounterDoc()

protected MOrder createCounterDoc()
{
    //  Is this itself a counter doc ?
    if (getRef_Order_ID() != 0)
        return null;

    //  Org Must be linked to BPartner
    MOrg org = MOrg.get(getCtx(), getAD_Org_ID());
    int counterC_BPartner_ID = org.getLinkedC_BPartner_ID(get_TrxName());
    if (counterC_BPartner_ID == 0)
        return null;
    //  Business Partner needs to be linked to Org
    MBPartner bp = new MBPartner (getCtx(), getC_BPartner_ID(), get_TrxName());
    int counterAD_Org_ID = bp.getAD_OrgBP_ID();
    if (counterAD_Org_ID == 0)
        return null;

    MBPartner counterBP = new MBPartner (getCtx(), counterC_BPartner_ID, null);
    MOrgInfo counterOrgInfo = MOrgInfo.get(getCtx(), counterAD_Org_ID, get_TrxName());

    //  Document Type
    int C_DocTypeTarget_ID = 0;
    MDocTypeCounter counterDT = MDocTypeCounter.getCounterDocType(getCtx(), getC_DocType_ID());
    if (counterDT != null)
    {
        if (!counterDT.isCreateCounter() || !counterDT.isValid())
            return null;
        C_DocTypeTarget_ID = counterDT.getCounter_C_DocType_ID();
    }
    else    //  indirect
    {
        C_DocTypeTarget_ID = MDocTypeCounter.getCounterDocType_ID(getCtx(), getC_DocType_ID());
        if (C_DocTypeTarget_ID <= 0)
            return null;
    }
    //  Deep Copy
    MOrder counter = copyFrom (this, getDateOrdered(),
        C_DocTypeTarget_ID, !isSOTrx(), true, false, get_TrxName());
    //
    counter.setAD_Org_ID(counterAD_Org_ID);
    counter.setM_Warehouse_ID(counterOrgInfo.getM_Warehouse_ID());
    counter.setDatePromised(getDatePromised());  // default is date ordered
    //  References (Should not be required)
    counter.setSalesRep_ID(getSalesRep_ID());
    counter.saveEx(get_TrxName());

    //  Update copied lines
    MOrderLine[] counterLines = counter.getLines(true, null);
    for (int i = 0; i < counterLines.length; i++)
    {
        MOrderLine counterLine = counterLines[i];
        counterLine.setOrder(counter);  //  copies header values (BP, etc.)
        counterLine.setTax();
        counterLine.saveEx(get_TrxName());
    }

    //  Document Action
    if (counterDT != null)
    {
        if (counterDT.getDocAction() != null)
        {
            counter.setDocAction(counterDT.getDocAction());
            if (!counter.processIt(counterDT.getDocAction()))
                throw new AdempiereException(
                    Msg.getMsg(getCtx(), "FailedProcessingDocument")
                    + " - " + counter.getProcessMsg());
            counter.saveEx(get_TrxName());
        }
    }
    return counter;
}   //  createCounterDoc

Organization-to-BPartner Mapping

The getLinkedC_BPartner_ID() method in MOrg performs the critical org-to-business-partner lookup. This is what enables inter-organization trading — each organization that participates in inter-org transactions must be registered as a business partner in the other organization's books:

// From org.compiere.model.MOrg

public int getLinkedC_BPartner_ID(String trxName)
{
    if (m_linkedBPartner == null)
    {
        int C_BPartner_ID = DB.getSQLValue(trxName,
            "SELECT C_BPartner_ID FROM C_BPartner WHERE AD_OrgBP_ID=?",
            getAD_Org_ID());
        if (C_BPartner_ID < 0)   //  not found = -1
            C_BPartner_ID = 0;
        m_linkedBPartner = Integer.valueOf(C_BPartner_ID);
    }
    return m_linkedBPartner.intValue();
}   //  getLinkedC_BPartner_ID

The SQL query SELECT C_BPartner_ID FROM C_BPartner WHERE AD_OrgBP_ID=? looks for a business partner whose AD_OrgBP_ID field matches the current organization's AD_Org_ID. This mapping must be established in the business partner master data before counter documents can function.

The Deep Copy via copyFrom()

A critical aspect of counter document creation is the deep copy performed by MOrder.copyFrom(). When called with counter=true, this method flips the transaction direction (!isSOTrx()), sets the Ref_Order_ID cross-reference on both documents, and copies all lines:

// From MOrder.copyFrom() — counter-specific logic

if (counter) {
    to.setRef_Order_ID(from.getC_Order_ID());
    MOrg org = MOrg.get(from.getCtx(), from.getAD_Org_ID());
    int counterC_BPartner_ID = org.getLinkedC_BPartner_ID(trxName);
    if (counterC_BPartner_ID == 0)
        return null;
    to.setBPartner(MBPartner.get(from.getCtx(), counterC_BPartner_ID));
} else
    to.setRef_Order_ID(0);

// ... after lines are copied:

// don't copy linked PO/SO
to.setLink_Order_ID(0);

Notice the final line: to.setLink_Order_ID(0). This explicitly clears any Link_Order_ID that might have been carried over from the source order during the value copy. The counter document uses Ref_Order_ID for its cross-reference, not Link_Order_ID.

Counter Document Line Handling

The copyLinesFrom() method similarly handles line-level cross-references for counter documents:

// From MOrder.copyLinesFrom() — counter line logic

if (counter)
    line.setRef_OrderLine_ID(fromLines[i].getC_OrderLine_ID());
else
    line.setRef_OrderLine_ID(0);

// don't copy linked lines
line.setLink_OrderLine_ID(0);

//  Cross Link
if (counter)
{
    fromLines[i].setRef_OrderLine_ID(line.getC_OrderLine_ID());
    fromLines[i].saveEx(get_TrxName());
}

This creates a bidirectional reference via Ref_OrderLine_ID — each line in the original order points to its corresponding counter line, and each counter line points back. Meanwhile, Link_OrderLine_ID is explicitly cleared on the new lines to prevent contamination from the source.

Counter Document Flow Visualization

Organization A (Sales Org)                   Organization B (Purchasing Org)
================================             ================================
Sales Order #1001                            Purchase Order #5001
  IsSOTrx = true                               IsSOTrx = false
  C_BPartner_ID = (Org B as BP)                C_BPartner_ID = (Org A as BP)
  Ref_Order_ID = 5001  <----+         +----->  Ref_Order_ID = 1001
                             |         |
  SO Line 10                 |         |        PO Line 10
    Ref_OrderLine_ID --------+---------+---->     Ref_OrderLine_ID
    Link_OrderLine_ID = 0    |                    Link_OrderLine_ID = 0
                             |
  completeIt()               |
    -> createCounterDoc() ---+
       MDocTypeCounter determines target doc type
       copyFrom(this, date, targetDocType, !isSOTrx, counter=true, ...)
       Org/Warehouse set from counterOrgInfo
       Lines updated with setOrder(counter) and setTax()

Drop-Ship Flow

Drop-shipping is a fulfillment strategy where the vendor ships goods directly to the customer, bypassing the purchasing organization's warehouse entirely. In iDempiere, this is implemented through the IsDropShip flag on orders and shipments, combined with the Link_Order_ID mechanism to connect the purchase order to the originating sales order.

Drop-Ship Fields on C_Order

The I_C_Order interface defines four drop-ship-specific fields:

// From org.compiere.model.I_C_Order

/** Column name DropShip_BPartner_ID */
public static final String COLUMNNAME_DropShip_BPartner_ID = "DropShip_BPartner_ID";

/** Set Drop Ship Business Partner.
  * Business Partner to ship to
  */
public void setDropShip_BPartner_ID (int DropShip_BPartner_ID);

/** Get Drop Ship Business Partner.
  * Business Partner to ship to
  */
public int getDropShip_BPartner_ID();

/** Column name DropShip_Location_ID */
public static final String COLUMNNAME_DropShip_Location_ID = "DropShip_Location_ID";

/** Set Drop Shipment Location.
  * Business Partner Location for shipping to
  */
public void setDropShip_Location_ID (int DropShip_Location_ID);

/** Get Drop Shipment Location.
  * Business Partner Location for shipping to
  */
public int getDropShip_Location_ID();

/** Column name DropShip_User_ID */
public static final String COLUMNNAME_DropShip_User_ID = "DropShip_User_ID";

/** Set Drop Shipment Contact.
  * Business Partner Contact for drop shipment
  */
public void setDropShip_User_ID (int DropShip_User_ID);

/** Get Drop Shipment Contact.
  * Business Partner Contact for drop shipment
  */
public int getDropShip_User_ID();
Field Column Name Description
IsDropShip IsDropShip Boolean flag indicating this order/shipment is a drop-ship transaction
DropShip_BPartner_ID DropShip_BPartner_ID The end customer who will receive the goods (the customer from the SO)
DropShip_Location_ID DropShip_Location_ID The shipping address for the drop-ship delivery
DropShip_User_ID DropShip_User_ID The contact person at the drop-ship destination

Drop-Ship Flag Initialization

When a new MOrder is created, the drop-ship flag is explicitly initialized to false:

// From MOrder constructor defaults
setIsDropShip(false);

The setter method in MOrder simply delegates to the generated superclass:

// From MOrder.java

/**
 *  Set Drop Ship
 *  @param IsDropShip drop ship
 */
public void setIsDropShip (boolean IsDropShip)
{
    super.setIsDropShip (IsDropShip);
}   //  setIsDropShip

Drop-Ship Data Propagation to MInOut

When a Material Receipt (MInOut) is created from a drop-ship purchase order, the drop-ship fields are propagated. The MInOut constructor that accepts an MOrder copies all four drop-ship fields:

// From MInOut constructor (MOrder-based) — lines 676-679

// Drop shipment
setIsDropShip(order.isDropShip());
setDropShip_BPartner_ID(order.getDropShip_BPartner_ID());
setDropShip_Location_ID(order.getDropShip_Location_ID());
setDropShip_User_ID(order.getDropShip_User_ID());

This same propagation occurs in the invoice-based constructor and the copy constructor of MInOut, ensuring consistency across all creation paths.

createDropShipment() in MInOut

The core of the drop-ship flow is the createDropShipment() method in MInOut. This method is called during completeIt() of a material receipt and automatically creates a customer shipment when the receipt is for a drop-ship purchase order:

// From MInOut.completeIt() — lines 2156-2160

//  Drop Shipments
MInOut dropShipment = createDropShipment();
if (dropShipment != null)
{
    info.append(" - @DropShipment@: @M_InOut_ID@=").append(dropShipment.getDocumentNo());
    // ... event listener for post-processing
}

The complete createDropShipment() method is reproduced below with detailed annotations:

// From org.compiere.model.MInOut — createDropShipment()

/**
 * Automatically creates a customer shipment for any
 * drop shipment material receipt.
 * Based on createCounterDoc() by JJ.
 * @return shipment if created else null
 */
protected MInOut createDropShipment() {

    // Guard clause: only for purchase-side, drop-ship, order-linked receipts
    if ( isSOTrx() || !isDropShip() || getC_Order_ID() == 0 )
        return null;

    // Navigate from PO -> linked SO via Link_Order_ID
    int linkedOrderID = new MOrder (getCtx(), getC_Order_ID(), get_TrxName())
        .getLink_Order_ID();
    if (linkedOrderID <= 0)
        return null;

    //  Document Type - find a Material Delivery doc type
    int C_DocTypeTarget_ID = 0;
    MDocType[] shipmentTypes = MDocType.getOfDocBaseType(getCtx(),
        MDocType.DOCBASETYPE_MaterialDelivery);
    for (int i = 0; i < shipmentTypes.length; i++ )
    {
        if (shipmentTypes[i].isSOTrx()
            && ( C_DocTypeTarget_ID == 0 || shipmentTypes[i].isDefault() ) )
            C_DocTypeTarget_ID = shipmentTypes[i].getC_DocType_ID();
    }

    //  Deep Copy from the receipt
    MInOut dropShipment = copyFrom(this, getMovementDate(), getDateAcct(),
        C_DocTypeTarget_ID, !isSOTrx(), false, get_TrxName(), true);

    // Point the shipment to the linked Sales Order
    dropShipment.setC_Order_ID(linkedOrderID);

    // get invoice id from linked order
    int invID = new MOrder (getCtx(), linkedOrderID, get_TrxName())
        .getC_Invoice_ID();
    if ( invID != 0 )
        dropShipment.setC_Invoice_ID(invID);

    // Set the drop-ship customer as the shipment's business partner
    dropShipment.setC_BPartner_ID(getDropShip_BPartner_ID());
    dropShipment.setC_BPartner_Location_ID(getDropShip_Location_ID());
    dropShipment.setAD_User_ID(getDropShip_User_ID());

    // Clear drop-ship flags on the generated shipment (it IS the shipment)
    dropShipment.setIsDropShip(false);
    dropShipment.setDropShip_BPartner_ID(0);
    dropShipment.setDropShip_Location_ID(0);
    dropShipment.setDropShip_User_ID(0);

    dropShipment.setMovementType(MOVEMENTTYPE_CustomerShipment);

    // Copy tracking info if available
    if (!Util.isEmpty(getTrackingNo()) && getM_Shipper_ID() > 0 &&
            DELIVERYVIARULE_Shipper.equals(getDeliveryViaRule()))
    {
        dropShipment.setTrackingNo(getTrackingNo());
        dropShipment.setDeliveryViaRule(DELIVERYVIARULE_Shipper);
        dropShipment.setM_Shipper_ID(getM_Shipper_ID());
    }

    dropShipment.setSalesRep_ID(getSalesRep_ID());
    dropShipment.saveEx(get_TrxName());

    //  Update line order references to linked sales order lines
    MInOutLine[] lines = dropShipment.getLines(true);
    for (int i = 0; i < lines.length; i++)
    {
        MInOutLine dropLine = lines[i];
        MOrderLine ol = new MOrderLine(getCtx(),
            dropLine.getC_OrderLine_ID(), get_TrxName());
        if ( ol.getC_OrderLine_ID() != 0 ) {
            // Remap from PO line to the linked SO line
            dropLine.setC_OrderLine_ID(ol.getLink_OrderLine_ID());
            dropLine.saveEx();
        }
    }

    // do not post immediate dropshipment,
    // should post after source shipment
    dropShipment.set_Attribute(
        DocumentEngine.DOCUMENT_POST_IMMEDIATE_AFTER_COMPLETE,
        Boolean.FALSE);
    ProcessInfo processInfo = MWorkflow.runDocumentActionWorkflow(
        dropShipment, DocAction.ACTION_Complete);
    if (processInfo.isError())
        throw new RuntimeException(
            Msg.getMsg(getCtx(), "FailedProcessingDocument")
            + ": " + dropShipment.toString()
            + " - " + dropShipment.getProcessMsg());
    dropShipment.saveEx();

    return dropShipment;
}

Drop-Ship Line Remapping

One of the most important details in createDropShipment() is the line-level remapping. The receipt lines reference PO lines via C_OrderLine_ID. But the generated customer shipment needs to reference the original SO lines. The method accomplishes this by traversing the Link_OrderLine_ID chain:

// Line remapping: PO line -> SO line via Link_OrderLine_ID
MOrderLine ol = new MOrderLine(getCtx(),
    dropLine.getC_OrderLine_ID(), get_TrxName());
if ( ol.getC_OrderLine_ID() != 0 ) {
    dropLine.setC_OrderLine_ID(ol.getLink_OrderLine_ID());
    dropLine.saveEx();
}

This traversal — from PO line to its linked SO line — is only possible because Link_OrderLine_ID was set when the PO was originally generated from the SO.

Drop-Ship in Cost Detail Processing

The Link_Order_ID and isDropShip() checks also appear in MInOut's cost-related logic to handle the unique costing implications of drop-shipments, where no physical inventory movement through the warehouse occurs:

// From MInOut — cost detail processing

MOrder sOrder = new MOrder(getCtx(), inout.getC_Order_ID(), get_TrxName());
if (sOrder.getLink_Order_ID() > 0) {
    MOrder lOrder = new MOrder(getCtx(), sOrder.getLink_Order_ID(), get_TrxName());
    if (lOrder.isDropShip()) // drop shipment
        continue;  // skip normal costing for drop-ship
}

End-to-End Drop-Ship Flow

1. Customer places order
   +--> Sales Order (SO #1001, IsSOTrx=true)
        C_BPartner_ID = Customer
        IsDropShip = true
        DropShip_BPartner_ID = Customer
        DropShip_Location_ID = Customer's Ship-To
        DropShip_User_ID = Customer's Contact

2. Create linked Purchase Order to vendor
   +--> Purchase Order (PO #2001, IsSOTrx=false)
        C_BPartner_ID = Vendor
        Link_Order_ID = 1001  (points to SO)
        IsDropShip = true
        DropShip_BPartner_ID = Customer
        DropShip_Location_ID = Customer's Ship-To
        DropShip_User_ID = Customer's Contact

        PO Line 10:
          Link_OrderLine_ID = (SO Line 10 ID)

3. Vendor ships, we create Material Receipt
   +--> Material Receipt (MR #3001, IsSOTrx=false)
        C_Order_ID = 2001  (references PO)
        IsDropShip = true
        DropShip_BPartner_ID = Customer
        DropShip_Location_ID = Customer's Ship-To

4. MInOut.completeIt() triggers createDropShipment()
   +--> Customer Shipment (CS #4001, IsSOTrx=true)  [AUTO-GENERATED]
        C_Order_ID = 1001  (remapped to linked SO)
        C_BPartner_ID = Customer
        IsDropShip = false  (cleared)
        DropShip_BPartner_ID = 0  (cleared)
        MovementType = Customer Shipment

        CS Line 10:
          C_OrderLine_ID = (SO Line 10)  (remapped via Link_OrderLine_ID)

Blanket Purchase Order Pattern

A blanket purchase order (BPO) is a master contract with a vendor committing to purchase a specified total quantity of products over a defined period, typically at a negotiated price. Individual release orders are then created against the blanket to request actual deliveries. iDempiere supports this pattern through the Link_OrderLine_ID field on release order lines.

Blanket Order Structure

The blanket PO is a standard purchase order (C_Order with IsSOTrx=false) that represents the overall commitment. Its lines define the contracted products, quantities, and prices. The order itself is typically completed but not closed, allowing release orders to be drawn against it over time.

Blanket PO Component Table/Column Purpose
Master Order C_Order The contract header — vendor, dates, terms
Contract Lines C_OrderLine Products, committed quantities, contracted prices
Contract Period C_Order.DatePromised The expiration date of the blanket agreement
Contracted Price C_OrderLine.PriceActual The negotiated unit price for the contract period
Committed Quantity C_OrderLine.QtyOrdered The total quantity committed under the contract

Release Orders and Link_OrderLine_ID

Each release order is a separate purchase order whose lines reference the blanket order lines through Link_OrderLine_ID. This creates a traceable chain from every delivery request back to the master contract:

// Creating a release order line linked to a blanket PO line
MOrderLine releaseLine = new MOrderLine(releaseOrder);
releaseLine.setM_Product_ID(blanketLine.getM_Product_ID());
releaseLine.setQtyOrdered(new BigDecimal("100")); // release quantity
releaseLine.setPriceActual(blanketLine.getPriceActual()); // contract price
releaseLine.setLink_OrderLine_ID(blanketLine.getC_OrderLine_ID());
releaseLine.saveEx();

Tracking Consumed Quantity

The key operational question for any blanket order is: how much of the committed quantity has been consumed by release orders? Since iDempiere does not maintain a running balance on the blanket line itself, you calculate consumption by summing the quantities of all release order lines that reference each blanket line:

-- Query: Blanket Order Consumption Status
SELECT
    bpo.DocumentNo                    AS blanket_po,
    bpo.DatePromised                  AS contract_expiry,
    bp.Name                           AS vendor_name,
    p.Name                            AS product_name,
    bl.Line                           AS blanket_line_no,
    bl.QtyOrdered                     AS committed_qty,
    COALESCE(SUM(rl.QtyOrdered), 0)   AS released_qty,
    bl.QtyOrdered
      - COALESCE(SUM(rl.QtyOrdered), 0) AS remaining_qty,
    bl.PriceActual                    AS contract_price
FROM C_Order bpo
JOIN C_OrderLine bl ON bpo.C_Order_ID = bl.C_Order_ID
JOIN C_BPartner bp ON bpo.C_BPartner_ID = bp.C_BPartner_ID
JOIN M_Product p ON bl.M_Product_ID = p.M_Product_ID
LEFT JOIN C_OrderLine rl ON rl.Link_OrderLine_ID = bl.C_OrderLine_ID
LEFT JOIN C_Order ro ON rl.C_Order_ID = ro.C_Order_ID
    AND ro.DocStatus IN ('CO', 'CL')
WHERE bpo.C_Order_ID = ?           -- Blanket PO ID
    AND bpo.IsSOTrx = 'N'
GROUP BY
    bpo.DocumentNo, bpo.DatePromised, bp.Name,
    p.Name, bl.Line, bl.QtyOrdered, bl.PriceActual
ORDER BY bl.Line;

The LEFT JOIN C_OrderLine rl ON rl.Link_OrderLine_ID = bl.C_OrderLine_ID is the critical join — it finds all release order lines that reference each blanket line. The SUM(rl.QtyOrdered) gives the total released quantity, and subtracting from the blanket's QtyOrdered yields the remaining balance.

Remaining Quantity Calculation

For programmatic validation, you can calculate the remaining quantity available on a blanket line:

/**
 * Calculate remaining quantity available on a blanket order line.
 * @param blanketLineId the C_OrderLine_ID of the blanket line
 * @param trxName transaction name
 * @return remaining quantity (committed - released)
 */
public static BigDecimal getRemainingBlanketQty(int blanketLineId, String trxName) {
    String sql = "SELECT COALESCE(SUM(rl.QtyOrdered), 0) "
        + "FROM C_OrderLine rl "
        + "JOIN C_Order ro ON rl.C_Order_ID = ro.C_Order_ID "
        + "WHERE rl.Link_OrderLine_ID = ? "
        + "AND ro.DocStatus IN ('CO', 'CL')";

    BigDecimal releasedQty = DB.getSQLValueBD(trxName, sql, blanketLineId);
    if (releasedQty == null)
        releasedQty = Env.ZERO;

    MOrderLine blanketLine = new MOrderLine(Env.getCtx(), blanketLineId, trxName);
    BigDecimal committedQty = blanketLine.getQtyOrdered();

    return committedQty.subtract(releasedQty);
}

Business Rules for Release Validation

When implementing blanket order functionality, you should enforce these validation rules through a ModelValidator:

  1. Quantity Check: The release quantity must not exceed the remaining blanket balance.
  2. Price Consistency: The release order line price should match the blanket contract price (or a documented override must be provided).
  3. Contract Expiration: The release order's DateOrdered must fall within the blanket order's contract period (before DatePromised).
  4. Product Match: The release line's product must match the blanket line's product.
  5. Vendor Match: The release order's vendor must match the blanket order's vendor.
// Example ModelValidator for blanket order release validation
public String modelChange(PO po, int type) throws Exception {
    if (po instanceof MOrderLine && type == ModelValidator.TYPE_BEFORE_NEW) {
        MOrderLine releaseLine = (MOrderLine) po;
        int linkLineId = releaseLine.getLink_OrderLine_ID();

        if (linkLineId > 0) {
            MOrderLine blanketLine = new MOrderLine(
                po.getCtx(), linkLineId, po.get_TrxName());
            MOrder blanketOrder = new MOrder(
                po.getCtx(), blanketLine.getC_Order_ID(), po.get_TrxName());
            MOrder releaseOrder = new MOrder(
                po.getCtx(), releaseLine.getC_Order_ID(), po.get_TrxName());

            // 1. Product match
            if (releaseLine.getM_Product_ID() != blanketLine.getM_Product_ID())
                return "Release product does not match blanket line product";

            // 2. Vendor match
            if (releaseOrder.getC_BPartner_ID() != blanketOrder.getC_BPartner_ID())
                return "Release vendor does not match blanket order vendor";

            // 3. Contract expiration
            if (releaseOrder.getDateOrdered().after(blanketOrder.getDatePromised()))
                return "Release date exceeds blanket order expiration";

            // 4. Quantity check
            BigDecimal remaining = getRemainingBlanketQty(
                linkLineId, po.get_TrxName());
            if (releaseLine.getQtyOrdered().compareTo(remaining) > 0)
                return "Release qty (" + releaseLine.getQtyOrdered()
                    + ") exceeds remaining blanket balance (" + remaining + ")";
        }
    }
    return null;
}

voidIt() and Link Cleanup

When an order is voided, iDempiere must carefully clean up all linked references to prevent orphaned pointers — situations where one document references another that no longer exists or is no longer valid. The voidIt() method in MOrder handles this at both the header and line levels.

Header-Level Link Cleanup

Before processing lines, voidIt() checks for and clears the header-level linked order reference:

// From MOrder.voidIt() — lines 2688-2692

if (getLink_Order_ID() > 0) {
    MOrder so = new MOrder(getCtx(), getLink_Order_ID(), get_TrxName());
    so.setLink_Order_ID(0);
    so.saveEx();
}

This code does two things: it loads the linked order (typically the SO that generated this PO) and clears that order's Link_Order_ID. Note that it clears the link on the other order, not on the current one. The current order is being voided and will have its quantities zeroed out, but the referenced order needs its link cleared so it does not point to a voided document.

Line-Level Link Cleanup

After zeroing out quantities on each line, voidIt() performs the same cleanup at the line level:

// From MOrder.voidIt() — lines 2702-2723

MOrderLine[] lines = getLines(true, MOrderLine.COLUMNNAME_M_Product_ID);
for (int i = 0; i < lines.length; i++)
{
    MOrderLine line = lines[i];
    BigDecimal old = line.getQtyOrdered();
    if (old.signum() != 0)
    {
        line.addDescription(Msg.getMsg(getCtx(), "Voided") + " (" + old + ")");
        line.setQty(Env.ZERO);
        line.setLineNetAmt(Env.ZERO);
        line.saveEx(get_TrxName());
    }
    if (!isSOTrx())
    {
        deleteMatchPOCostDetail(line);
    }
    if (line.getLink_OrderLine_ID() > 0) {
        MOrderLine soline = new MOrderLine(getCtx(),
            line.getLink_OrderLine_ID(), get_TrxName());
        soline.setLink_OrderLine_ID(0);
        soline.saveEx();
    }
}

For each line that has a Link_OrderLine_ID, the method loads the linked line and clears its Link_OrderLine_ID reference. This ensures that if the voided order was a PO generated from an SO, the SO lines no longer believe they have an active linked PO line.

Additional Void Cleanup

The voidIt() method also handles several other cleanup operations after the link clearing:

// From MOrder.voidIt() — lines 2725-2757

// update taxes
MOrderTax[] taxes = getTaxes(true);
for (MOrderTax tax : taxes )
{
    if ( !(tax.calculateTaxFromLines() && tax.save()) )
        return false;
}

addDescription(Msg.getMsg(getCtx(), "Voided"));
//  Clear Reservations
if (!reserveStock(null, lines))
{
    m_processMsg = "Cannot unreserve Stock (void)";
    return false;
}

// UnLink All Requisitions
MRequisitionLine.unlinkC_Order_ID(getCtx(), get_ID(), get_TrxName());

/* globalqss - Reactivating/Voiding order must reset posted */
MFactAcct.deleteEx(MOrder.Table_ID, getC_Order_ID(), get_TrxName());
setPosted(false);

// After Void
m_processMsg = ModelValidationEngine.get().fireDocValidate(
    this, ModelValidator.TIMING_AFTER_VOID);

setTotalLines(Env.ZERO);
setGrandTotal(Env.ZERO);
setProcessed(true);
setDocAction(DOCACTION_None);

copyFrom() Also Clears Links

It is worth reiterating that MOrder.copyFrom() explicitly clears both link fields when creating copies, preventing accidental link propagation:

// From MOrder.copyFrom()
// don't copy linked PO/SO
to.setLink_Order_ID(0);

// From MOrder.copyLinesFrom()
// don't copy linked lines
line.setLink_OrderLine_ID(0);

This defensive coding ensures that copying an order (whether for counter documents, duplicating, or any other purpose) never creates false link relationships.

Contract Pricing Enforcement

One of the primary benefits of blanket purchase orders is price stability — the vendor commits to a fixed price over the contract period, protecting the buyer from market fluctuations. Enforcing this contracted price on release orders is a critical business requirement.

Price Fields on C_OrderLine

iDempiere's C_OrderLine table has several pricing fields that play different roles in contract pricing:

Field Description Role in Contract Pricing
PriceList List price from the price list version Reference price — may differ from contract price
PriceActual Actual unit price after discounts Should match the blanket line's PriceActual for contract releases
PriceEntered Price as entered by the user (before UOM conversion) User-facing price that maps to PriceActual
Discount Discount percentage from list price Computed as (PriceList - PriceActual) / PriceList * 100
PriceLimit Minimum price allowed Can enforce price floor for contract pricing

Enforcing Contract Price on Release Orders

When a release order line references a blanket line via Link_OrderLine_ID, the system (or a custom validator) should ensure the release line inherits the contracted price. Here is a pattern for implementing this enforcement:

/**
 * ModelValidator to enforce blanket order contract pricing.
 * Fires on TYPE_BEFORE_NEW and TYPE_BEFORE_CHANGE for MOrderLine.
 */
public String modelChange(PO po, int type) throws Exception {
    if (!(po instanceof MOrderLine))
        return null;
    if (type != ModelValidator.TYPE_BEFORE_NEW
        && type != ModelValidator.TYPE_BEFORE_CHANGE)
        return null;

    MOrderLine releaseLine = (MOrderLine) po;
    int linkLineId = releaseLine.getLink_OrderLine_ID();
    if (linkLineId <= 0)
        return null;  // not a release line

    MOrderLine blanketLine = new MOrderLine(
        po.getCtx(), linkLineId, po.get_TrxName());

    BigDecimal contractPrice = blanketLine.getPriceActual();
    BigDecimal releasePrice = releaseLine.getPriceActual();

    if (contractPrice != null && releasePrice != null
        && contractPrice.compareTo(releasePrice) != 0)
    {
        // Option A: Auto-correct to contract price
        releaseLine.setPriceActual(contractPrice);
        releaseLine.setPriceEntered(contractPrice);
        releaseLine.setLineNetAmt(contractPrice.multiply(
            releaseLine.getQtyOrdered()));

        // Option B: Reject with error (uncomment to use)
        // return "Release price (" + releasePrice
        //     + ") does not match contract price (" + contractPrice + ")";
    }

    return null;
}

Price Override Detection

In some organizations, authorized users may be allowed to override contract pricing with proper justification. You can implement a price override detection and audit pattern:

-- Detect release orders where price differs from blanket contract price
SELECT
    ro.DocumentNo          AS release_order,
    rl.Line                AS release_line,
    p.Name                 AS product,
    bl.PriceActual         AS contract_price,
    rl.PriceActual         AS release_price,
    rl.PriceActual - bl.PriceActual AS price_variance,
    CASE
        WHEN rl.PriceActual > bl.PriceActual THEN 'OVERCHARGE'
        WHEN rl.PriceActual < bl.PriceActual THEN 'DISCOUNT'
        ELSE 'MATCH'
    END                    AS variance_type,
    ro.CreatedBy           AS created_by_user
FROM C_OrderLine rl
JOIN C_Order ro ON rl.C_Order_ID = ro.C_Order_ID
JOIN C_OrderLine bl ON rl.Link_OrderLine_ID = bl.C_OrderLine_ID
JOIN M_Product p ON rl.M_Product_ID = p.M_Product_ID
WHERE rl.Link_OrderLine_ID > 0
    AND rl.PriceActual <> bl.PriceActual
    AND ro.DocStatus IN ('CO', 'CL')
ORDER BY ABS(rl.PriceActual - bl.PriceActual) DESC;

Contract Monitoring and Expiration

Effective contract management requires ongoing monitoring of contract utilization, upcoming expirations, and consumption patterns. iDempiere provides the data structures to support this; the monitoring logic is typically implemented through custom reports and scheduled processes.

DatePromised as Contract Expiration

For blanket purchase orders, the DatePromised field on the order header serves as the contract expiration date. This is the date by which all releases should be completed. The field is preserved during counter document creation:

// From MOrder.createCounterDoc()
counter.setDatePromised(getDatePromised());  // default is date ordered

Contract Status Dashboard Query

This comprehensive SQL query provides a full contract status dashboard for all active blanket purchase orders:

-- Blanket Purchase Order Status Dashboard
SELECT
    bpo.DocumentNo                              AS contract_no,
    bp.Name                                     AS vendor,
    bpo.DateOrdered                             AS contract_start,
    bpo.DatePromised                            AS contract_expiry,
    CASE
        WHEN bpo.DatePromised < CURRENT_DATE THEN 'EXPIRED'
        WHEN bpo.DatePromised < CURRENT_DATE + INTERVAL '30 days'
            THEN 'EXPIRING_SOON'
        ELSE 'ACTIVE'
    END                                         AS contract_status,
    COUNT(DISTINCT bl.C_OrderLine_ID)           AS contract_lines,
    COUNT(DISTINCT rl.C_OrderLine_ID)           AS release_lines,
    SUM(bl.QtyOrdered)                          AS total_committed_qty,
    COALESCE(SUM(release_totals.released_qty), 0) AS total_released_qty,
    SUM(bl.QtyOrdered)
        - COALESCE(SUM(release_totals.released_qty), 0)
                                                AS total_remaining_qty,
    CASE
        WHEN SUM(bl.QtyOrdered) = 0 THEN 0
        ELSE ROUND(
            COALESCE(SUM(release_totals.released_qty), 0)
            / SUM(bl.QtyOrdered) * 100, 1)
    END                                         AS utilization_pct
FROM C_Order bpo
JOIN C_BPartner bp ON bpo.C_BPartner_ID = bp.C_BPartner_ID
JOIN C_OrderLine bl ON bpo.C_Order_ID = bl.C_Order_ID
LEFT JOIN (
    SELECT
        rl_inner.Link_OrderLine_ID,
        SUM(rl_inner.QtyOrdered) AS released_qty
    FROM C_OrderLine rl_inner
    JOIN C_Order ro_inner ON rl_inner.C_Order_ID = ro_inner.C_Order_ID
    WHERE rl_inner.Link_OrderLine_ID > 0
        AND ro_inner.DocStatus IN ('CO', 'CL')
    GROUP BY rl_inner.Link_OrderLine_ID
) release_totals ON bl.C_OrderLine_ID = release_totals.Link_OrderLine_ID
LEFT JOIN C_OrderLine rl ON rl.Link_OrderLine_ID = bl.C_OrderLine_ID
WHERE bpo.IsSOTrx = 'N'
    AND bpo.DocStatus IN ('CO', 'CL')
    -- Filter for blanket orders (customize this condition)
    AND bpo.Description LIKE '%Blanket%'
GROUP BY
    bpo.DocumentNo, bp.Name, bpo.DateOrdered, bpo.DatePromised
ORDER BY
    CASE
        WHEN bpo.DatePromised < CURRENT_DATE THEN 1
        WHEN bpo.DatePromised < CURRENT_DATE + INTERVAL '30 days' THEN 2
        ELSE 3
    END,
    bpo.DatePromised;

Expiring Contracts Alert Query

For automated alerts, you can run this query on a schedule to identify contracts approaching expiration that still have unreleased quantities:

-- Contracts expiring within 30 days with remaining balance
SELECT
    bpo.DocumentNo       AS contract_no,
    bp.Name              AS vendor,
    bpo.DatePromised     AS expiry_date,
    bpo.DatePromised - CURRENT_DATE AS days_remaining,
    p.Name               AS product,
    bl.QtyOrdered        AS committed_qty,
    COALESCE(SUM(rl.QtyOrdered), 0) AS released_qty,
    bl.QtyOrdered - COALESCE(SUM(rl.QtyOrdered), 0) AS unreleased_qty,
    bl.PriceActual       AS contract_price,
    (bl.QtyOrdered - COALESCE(SUM(rl.QtyOrdered), 0))
        * bl.PriceActual AS unreleased_value
FROM C_Order bpo
JOIN C_BPartner bp ON bpo.C_BPartner_ID = bp.C_BPartner_ID
JOIN C_OrderLine bl ON bpo.C_Order_ID = bl.C_Order_ID
JOIN M_Product p ON bl.M_Product_ID = p.M_Product_ID
LEFT JOIN C_OrderLine rl ON rl.Link_OrderLine_ID = bl.C_OrderLine_ID
    AND EXISTS (
        SELECT 1 FROM C_Order ro
        WHERE ro.C_Order_ID = rl.C_Order_ID
        AND ro.DocStatus IN ('CO', 'CL')
    )
WHERE bpo.IsSOTrx = 'N'
    AND bpo.DocStatus IN ('CO', 'CL')
    AND bpo.DatePromised BETWEEN CURRENT_DATE
        AND CURRENT_DATE + INTERVAL '30 days'
GROUP BY
    bpo.DocumentNo, bp.Name, bpo.DatePromised,
    p.Name, bl.QtyOrdered, bl.PriceActual
HAVING bl.QtyOrdered - COALESCE(SUM(rl.QtyOrdered), 0) > 0
ORDER BY bpo.DatePromised, unreleased_value DESC;

Monitoring Consumption Trends

Tracking how quickly blanket orders are being consumed helps procurement planners predict when new contracts need to be negotiated:

-- Monthly release trend for a blanket order
SELECT
    DATE_TRUNC('month', ro.DateOrdered)   AS release_month,
    p.Name                                AS product,
    COUNT(DISTINCT ro.C_Order_ID)         AS release_count,
    SUM(rl.QtyOrdered)                    AS monthly_released_qty,
    SUM(rl.QtyOrdered * rl.PriceActual)   AS monthly_released_value
FROM C_OrderLine rl
JOIN C_Order ro ON rl.C_Order_ID = ro.C_Order_ID
JOIN C_OrderLine bl ON rl.Link_OrderLine_ID = bl.C_OrderLine_ID
JOIN M_Product p ON rl.M_Product_ID = p.M_Product_ID
WHERE bl.C_Order_ID = ?  -- Blanket PO's C_Order_ID
    AND ro.DocStatus IN ('CO', 'CL')
GROUP BY DATE_TRUNC('month', ro.DateOrdered), p.Name
ORDER BY release_month, p.Name;

Release Order Workflow

Creating a release order from a blanket PO follows a specific sequence of steps to maintain proper document linkage and validate against contract terms. This section walks through the complete workflow both conceptually and in code.

Step-by-Step Process

  1. Identify the blanket PO and required products/quantities.
  2. Create a new purchase order with the same vendor, warehouse, and price list as the blanket PO.
  3. Add release lines for each product needed, setting Link_OrderLine_ID to the corresponding blanket line.
  4. Validate remaining blanket balance to ensure the release does not exceed the committed quantity.
  5. Validate contract pricing by matching prices to the blanket line.
  6. Validate contract expiration by checking the release date against DatePromised.
  7. Complete the release order to trigger the normal procurement flow (receipt, invoice, payment).

Complete Release Order Code Example

/**
 * Create a release order from a blanket purchase order.
 *
 * @param ctx             context
 * @param blanketOrderId  the C_Order_ID of the blanket PO
 * @param releaseItems    map of blanket C_OrderLine_ID -> release quantity
 * @param trxName         transaction name
 * @return the completed release order
 */
public static MOrder createReleaseOrder(
    Properties ctx,
    int blanketOrderId,
    java.util.Map<Integer, BigDecimal> releaseItems,
    String trxName)
{
    // 1. Load the blanket PO
    MOrder blanketPO = new MOrder(ctx, blanketOrderId, trxName);
    if (blanketPO.getC_Order_ID() == 0)
        throw new AdempiereException("Blanket PO not found: " + blanketOrderId);
    if (!blanketPO.getDocStatus().equals(MOrder.DOCSTATUS_Completed))
        throw new AdempiereException("Blanket PO must be Completed");

    // 2. Validate contract not expired
    Timestamp today = new Timestamp(System.currentTimeMillis());
    if (blanketPO.getDatePromised() != null
        && today.after(blanketPO.getDatePromised()))
        throw new AdempiereException("Blanket PO has expired on "
            + blanketPO.getDatePromised());

    // 3. Create the release order header
    MOrder releaseOrder = new MOrder(ctx, 0, trxName);
    releaseOrder.setAD_Org_ID(blanketPO.getAD_Org_ID());
    releaseOrder.setIsSOTrx(false);
    releaseOrder.setC_DocTypeTarget_ID(blanketPO.getC_DocTypeTarget_ID());
    releaseOrder.setC_BPartner_ID(blanketPO.getC_BPartner_ID());
    releaseOrder.setC_BPartner_Location_ID(
        blanketPO.getC_BPartner_Location_ID());
    releaseOrder.setM_Warehouse_ID(blanketPO.getM_Warehouse_ID());
    releaseOrder.setM_PriceList_ID(blanketPO.getM_PriceList_ID());
    releaseOrder.setPaymentRule(blanketPO.getPaymentRule());
    releaseOrder.setC_PaymentTerm_ID(blanketPO.getC_PaymentTerm_ID());
    releaseOrder.setDateOrdered(today);
    releaseOrder.setDatePromised(today); // delivery date for this release
    releaseOrder.setDescription("Release against Blanket PO "
        + blanketPO.getDocumentNo());
    releaseOrder.saveEx(trxName);

    // 4. Create release lines
    for (java.util.Map.Entry<Integer, BigDecimal> entry
        : releaseItems.entrySet())
    {
        int blanketLineId = entry.getKey();
        BigDecimal releaseQty = entry.getValue();

        MOrderLine blanketLine = new MOrderLine(ctx, blanketLineId, trxName);
        if (blanketLine.getC_OrderLine_ID() == 0)
            throw new AdempiereException(
                "Blanket line not found: " + blanketLineId);

        // 4a. Validate remaining balance
        BigDecimal remaining = getRemainingBlanketQty(blanketLineId, trxName);
        if (releaseQty.compareTo(remaining) > 0)
            throw new AdempiereException(
                "Release qty (" + releaseQty
                + ") exceeds remaining balance (" + remaining
                + ") for blanket line " + blanketLine.getLine());

        // 4b. Create the release line
        MOrderLine releaseLine = new MOrderLine(releaseOrder);
        releaseLine.setM_Product_ID(blanketLine.getM_Product_ID());
        releaseLine.setC_UOM_ID(blanketLine.getC_UOM_ID());
        releaseLine.setQtyEntered(releaseQty);
        releaseLine.setQtyOrdered(releaseQty);
        releaseLine.setPriceActual(blanketLine.getPriceActual());
        releaseLine.setPriceList(blanketLine.getPriceList());
        releaseLine.setPriceEntered(blanketLine.getPriceActual());
        releaseLine.setC_Tax_ID(blanketLine.getC_Tax_ID());
        releaseLine.setM_Warehouse_ID(blanketLine.getM_Warehouse_ID());
        releaseLine.setLink_OrderLine_ID(blanketLine.getC_OrderLine_ID());
        releaseLine.setDescription("Release from blanket line "
            + blanketLine.getLine());
        releaseLine.saveEx(trxName);
    }

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

    return releaseOrder;
}

Setting Link_OrderLine_ID References

The critical line in the above code is:

releaseLine.setLink_OrderLine_ID(blanketLine.getC_OrderLine_ID());

Recall that setLink_OrderLine_ID() uses set_ValueNoCheck internally, so this must be called programmatically — it cannot be set through the standard UI field editor. Custom processes, callouts, or model validators are the appropriate mechanisms for setting this field.

Quantity Validation Against Remaining Balance

The validation step ensures atomicity — if any line exceeds its available balance, the entire release order creation is rolled back (assuming proper transaction management). The getRemainingBlanketQty() helper method queries the sum of all completed release lines that reference the blanket line:

// Validation: release qty must not exceed remaining blanket balance
BigDecimal remaining = getRemainingBlanketQty(blanketLineId, trxName);
if (releaseQty.compareTo(remaining) > 0)
    throw new AdempiereException(
        "Release qty (" + releaseQty
        + ") exceeds remaining balance (" + remaining
        + ") for blanket line " + blanketLine.getLine());

Note that this query only counts release lines on orders with DocStatus IN ('CO', 'CL'). Draft and in-progress release orders are not counted against the consumed balance, which is a design choice — it means two concurrent release order creators could potentially over-commit the blanket. For strict control, you would also count lines on orders in draft status.

Complete Code Examples

This section provides self-contained code examples for the most common contract and blanket order operations.

Example 1: Creating a Blanket PO with Multiple Lines

/**
 * Create a blanket purchase order for a one-year contract.
 */
public static MOrder createBlanketPO(
    Properties ctx, int vendorId, int warehouseId,
    int priceListId, String trxName)
{
    MOrder blanketPO = new MOrder(ctx, 0, trxName);
    blanketPO.setAD_Org_ID(Env.getAD_Org_ID(ctx));
    blanketPO.setIsSOTrx(false);  // Purchase Order
    blanketPO.setC_DocTypeTarget_ID(
        MOrder.DocSubTypeSO_Standard);  // Standard PO type
    blanketPO.setC_BPartner_ID(vendorId);
    blanketPO.setM_Warehouse_ID(warehouseId);
    blanketPO.setM_PriceList_ID(priceListId);

    // Contract period: today to one year from now
    Timestamp today = new Timestamp(System.currentTimeMillis());
    blanketPO.setDateOrdered(today);
    blanketPO.setDatePromised(TimeUtil.addDays(today, 365));
    blanketPO.setDescription(
        "Blanket PO — Annual Contract " + today.toString().substring(0, 4));
    blanketPO.saveEx(trxName);

    // Line 1: Widget A — 10,000 units at $5.00
    MOrderLine line1 = new MOrderLine(blanketPO);
    line1.setM_Product_ID(1000001);  // Widget A product ID
    line1.setQtyOrdered(new BigDecimal("10000"));
    line1.setPriceActual(new BigDecimal("5.00"));
    line1.setPriceList(new BigDecimal("6.50"));
    line1.saveEx(trxName);

    // Line 2: Widget B — 5,000 units at $12.50
    MOrderLine line2 = new MOrderLine(blanketPO);
    line2.setM_Product_ID(1000002);  // Widget B product ID
    line2.setQtyOrdered(new BigDecimal("5000"));
    line2.setPriceActual(new BigDecimal("12.50"));
    line2.setPriceList(new BigDecimal("15.00"));
    line2.saveEx(trxName);

    // Line 3: Widget C — 20,000 units at $1.75
    MOrderLine line3 = new MOrderLine(blanketPO);
    line3.setM_Product_ID(1000003);  // Widget C product ID
    line3.setQtyOrdered(new BigDecimal("20000"));
    line3.setPriceActual(new BigDecimal("1.75"));
    line3.setPriceList(new BigDecimal("2.25"));
    line3.saveEx(trxName);

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

    return blanketPO;
}

Example 2: Creating a Release Order Linked to the Blanket

/**
 * Create a monthly release order for specific quantities
 * from an existing blanket PO.
 */
public static MOrder createMonthlyRelease(
    Properties ctx, MOrder blanketPO,
    int widgetALineId, BigDecimal widgetAQty,
    int widgetCLineId, BigDecimal widgetCQty,
    String trxName)
{
    java.util.Map<Integer, BigDecimal> releaseItems =
        new java.util.LinkedHashMap<>();

    // Release 800 of Widget A and 1,500 of Widget C
    releaseItems.put(widgetALineId, widgetAQty);
    releaseItems.put(widgetCLineId, widgetCQty);

    return createReleaseOrder(
        ctx, blanketPO.getC_Order_ID(), releaseItems, trxName);
}

Example 3: Querying Blanket Order Consumption Status

-- Complete consumption report for all blanket POs with a specific vendor
SELECT
    bpo.DocumentNo         AS blanket_po_no,
    bpo.DateOrdered        AS contract_start,
    bpo.DatePromised       AS contract_expiry,
    CASE
        WHEN bpo.DatePromised < CURRENT_DATE THEN 'EXPIRED'
        ELSE 'ACTIVE'
    END                    AS status,
    p.Value                AS product_code,
    p.Name                 AS product_name,
    bl.Line                AS line_no,
    bl.QtyOrdered          AS committed_qty,
    bl.PriceActual         AS contract_price,
    bl.QtyOrdered * bl.PriceActual AS committed_value,
    COALESCE(rel.released_qty, 0) AS released_qty,
    COALESCE(rel.release_count, 0) AS release_count,
    bl.QtyOrdered - COALESCE(rel.released_qty, 0) AS remaining_qty,
    (bl.QtyOrdered - COALESCE(rel.released_qty, 0))
        * bl.PriceActual   AS remaining_value,
    CASE
        WHEN bl.QtyOrdered = 0 THEN 0
        ELSE ROUND(
            COALESCE(rel.released_qty, 0)
            / bl.QtyOrdered * 100, 1)
    END                    AS pct_consumed
FROM C_Order bpo
JOIN C_OrderLine bl ON bpo.C_Order_ID = bl.C_Order_ID
JOIN M_Product p ON bl.M_Product_ID = p.M_Product_ID
LEFT JOIN (
    SELECT
        rl.Link_OrderLine_ID,
        SUM(rl.QtyOrdered) AS released_qty,
        COUNT(DISTINCT rl.C_Order_ID) AS release_count
    FROM C_OrderLine rl
    JOIN C_Order ro ON rl.C_Order_ID = ro.C_Order_ID
    WHERE rl.Link_OrderLine_ID > 0
        AND ro.DocStatus IN ('CO', 'CL')
    GROUP BY rl.Link_OrderLine_ID
) rel ON bl.C_OrderLine_ID = rel.Link_OrderLine_ID
WHERE bpo.C_BPartner_ID = ?   -- Vendor's C_BPartner_ID
    AND bpo.IsSOTrx = 'N'
    AND bpo.DocStatus IN ('CO', 'CL')
ORDER BY bpo.DocumentNo, bl.Line;

Example 4: Drop-Ship PO Creation from SO

/**
 * Create a drop-ship purchase order from a sales order.
 * The PO is linked to the SO via Link_Order_ID / Link_OrderLine_ID
 * so that MInOut.createDropShipment() can function when the
 * vendor ships the goods.
 */
public static MOrder createDropShipPO(
    Properties ctx, MOrder salesOrder, int vendorId, String trxName)
{
    if (!salesOrder.isSOTrx())
        throw new AdempiereException("Source must be a Sales Order");

    // Create the PO header
    MOrder dropShipPO = new MOrder(ctx, 0, trxName);
    dropShipPO.setAD_Org_ID(salesOrder.getAD_Org_ID());
    dropShipPO.setIsSOTrx(false);

    // Find default PO document type
    int poDocTypeId = MDocType.getOfDocBaseType(ctx,
        MDocType.DOCBASETYPE_PurchaseOrder)[0].getC_DocType_ID();
    dropShipPO.setC_DocTypeTarget_ID(poDocTypeId);

    dropShipPO.setC_BPartner_ID(vendorId);
    dropShipPO.setM_Warehouse_ID(salesOrder.getM_Warehouse_ID());
    dropShipPO.setDateOrdered(salesOrder.getDateOrdered());
    dropShipPO.setDatePromised(salesOrder.getDatePromised());

    // Set drop-ship fields — the SO's customer receives the goods
    dropShipPO.setIsDropShip(true);
    dropShipPO.setDropShip_BPartner_ID(salesOrder.getC_BPartner_ID());
    dropShipPO.setDropShip_Location_ID(
        salesOrder.getC_BPartner_Location_ID());
    dropShipPO.setDropShip_User_ID(salesOrder.getAD_User_ID());

    // Link PO to SO
    dropShipPO.setLink_Order_ID(salesOrder.getC_Order_ID());
    dropShipPO.setDescription("Drop-ship PO for SO "
        + salesOrder.getDocumentNo());
    dropShipPO.saveEx(trxName);

    // Create PO lines linked to SO lines
    MOrderLine[] soLines = salesOrder.getLines(true, null);
    for (MOrderLine soLine : soLines)
    {
        MOrderLine poLine = new MOrderLine(dropShipPO);
        poLine.setM_Product_ID(soLine.getM_Product_ID());
        poLine.setC_UOM_ID(soLine.getC_UOM_ID());
        poLine.setQtyEntered(soLine.getQtyEntered());
        poLine.setQtyOrdered(soLine.getQtyOrdered());
        // Vendor pricing (would typically come from vendor price list)
        poLine.setM_Warehouse_ID(soLine.getM_Warehouse_ID());

        // Link PO line to SO line
        poLine.setLink_OrderLine_ID(soLine.getC_OrderLine_ID());
        poLine.saveEx(trxName);
    }

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

    return dropShipPO;
}

Example 5: Contract Expiration Monitoring Query

-- Active contracts summary with expiration warnings
-- Suitable for a scheduled report or dashboard widget
SELECT
    bpo.DocumentNo           AS contract_no,
    bp.Name                  AS vendor,
    bpo.DatePromised         AS expiry_date,
    bpo.DatePromised - CURRENT_DATE AS days_until_expiry,
    CASE
        WHEN bpo.DatePromised < CURRENT_DATE
            THEN 'EXPIRED'
        WHEN bpo.DatePromised < CURRENT_DATE + INTERVAL '7 days'
            THEN 'CRITICAL'
        WHEN bpo.DatePromised < CURRENT_DATE + INTERVAL '30 days'
            THEN 'WARNING'
        WHEN bpo.DatePromised < CURRENT_DATE + INTERVAL '90 days'
            THEN 'APPROACHING'
        ELSE 'OK'
    END                      AS urgency,
    COALESCE(summary.total_committed, 0) AS total_committed_value,
    COALESCE(summary.total_released, 0)  AS total_released_value,
    COALESCE(summary.total_committed, 0)
        - COALESCE(summary.total_released, 0) AS unreleased_value,
    CASE
        WHEN COALESCE(summary.total_committed, 0) = 0 THEN 0
        ELSE ROUND(
            COALESCE(summary.total_released, 0)
            / summary.total_committed * 100, 1)
    END                      AS utilization_pct
FROM C_Order bpo
JOIN C_BPartner bp ON bpo.C_BPartner_ID = bp.C_BPartner_ID
LEFT JOIN (
    SELECT
        bl.C_Order_ID,
        SUM(bl.QtyOrdered * bl.PriceActual) AS total_committed,
        SUM(COALESCE(rel.released_value, 0)) AS total_released
    FROM C_OrderLine bl
    LEFT JOIN (
        SELECT
            rl.Link_OrderLine_ID,
            SUM(rl.QtyOrdered * rl.PriceActual) AS released_value
        FROM C_OrderLine rl
        JOIN C_Order ro ON rl.C_Order_ID = ro.C_Order_ID
        WHERE rl.Link_OrderLine_ID > 0
            AND ro.DocStatus IN ('CO', 'CL')
        GROUP BY rl.Link_OrderLine_ID
    ) rel ON bl.C_OrderLine_ID = rel.Link_OrderLine_ID
    GROUP BY bl.C_Order_ID
) summary ON bpo.C_Order_ID = summary.C_Order_ID
WHERE bpo.IsSOTrx = 'N'
    AND bpo.DocStatus IN ('CO', 'CL')
ORDER BY
    CASE
        WHEN bpo.DatePromised < CURRENT_DATE THEN 1
        WHEN bpo.DatePromised < CURRENT_DATE + INTERVAL '7 days' THEN 2
        WHEN bpo.DatePromised < CURRENT_DATE + INTERVAL '30 days' THEN 3
        WHEN bpo.DatePromised < CURRENT_DATE + INTERVAL '90 days' THEN 4
        ELSE 5
    END,
    bpo.DatePromised;

Common Pitfalls and Best Practices

Working with linked orders, counter documents, and drop-ship flows presents several challenges. Here are the most common issues and recommended practices.

Pitfall 1: Orphaned Link References

Problem: If a linked order is deleted (rather than voided) or manually modified, the link fields on the referencing documents become orphaned — they point to a non-existent or inconsistent record.

Solution: Always use voidIt() to cancel linked orders, never delete them. The voidIt() method in MOrder explicitly cleans up both header-level (Link_Order_ID) and line-level (Link_OrderLine_ID) references on the linked documents. If you must implement custom deletion logic, ensure you replicate this cleanup pattern.

Pitfall 2: Forgetting to Clear Links on copyFrom()

Problem: When copying an order (e.g., to create a new order based on an existing one), the Link_Order_ID and Link_OrderLine_ID values can be inadvertently carried over, creating false links.

Solution: iDempiere's MOrder.copyFrom() already handles this by explicitly setting to.setLink_Order_ID(0) and line.setLink_OrderLine_ID(0) on copied lines. If you implement custom order cloning logic, always include these clearing steps.

Pitfall 3: Concurrent Release Order Over-Commitment

Problem: Two users simultaneously creating release orders against the same blanket line could each pass the remaining-balance validation, resulting in total releases exceeding the commitment.

Solution: Implement pessimistic locking on the blanket order line when creating releases, or accept optimistic concurrency and validate at completion time. A ModelValidator on TIMING_BEFORE_COMPLETE of the release order can perform a final validation check within the completion transaction:

// Final validation at completion time
public String docValidate(PO po, int timing) {
    if (po instanceof MOrder && timing == ModelValidator.TIMING_BEFORE_COMPLETE) {
        MOrder order = (MOrder) po;
        for (MOrderLine line : order.getLines(true, null)) {
            if (line.getLink_OrderLine_ID() > 0) {
                BigDecimal remaining = getRemainingBlanketQty(
                    line.getLink_OrderLine_ID(), po.get_TrxName());
                if (line.getQtyOrdered().compareTo(remaining) > 0)
                    return "Blanket balance exceeded for line "
                        + line.getLine();
            }
        }
    }
    return null;
}

Pitfall 4: Drop-Ship Without Link_Order_ID

Problem: Creating a drop-ship PO without setting Link_Order_ID means that MInOut.createDropShipment() will not trigger when the material receipt is completed. The guard clause checks:

int linkedOrderID = new MOrder(getCtx(), getC_Order_ID(), get_TrxName())
    .getLink_Order_ID();
if (linkedOrderID <= 0)
    return null;  // No linked order = no drop shipment created

Solution: Always set Link_Order_ID on the PO (pointing to the SO) and Link_OrderLine_ID on PO lines (pointing to SO lines) when creating drop-ship purchase orders. Without these links, the automatic customer shipment will not be generated.

Pitfall 5: Counter Documents Creating Infinite Loops

Problem: If counter document type mappings are misconfigured, completing Order A in Org 1 could create counter Order B in Org 2, whose completion creates counter Order C in Org 1, and so on.

Solution: The createCounterDoc() method prevents this with its first guard clause:

// Is this itself a counter doc ?
if (getRef_Order_ID() != 0)
    return null;

Counter documents always have Ref_Order_ID set (pointing back to the original), so when they are completed, the createCounterDoc() method immediately returns null, breaking the chain. Never clear Ref_Order_ID on counter documents.

Pitfall 6: Incorrect set_ValueNoCheck Usage

Problem: Attempting to set Link_Order_ID or Link_OrderLine_ID through UI customization or standard callouts may fail silently because these fields use set_ValueNoCheck in the generated model.

Solution: These fields must be set programmatically — through processes, model validators, or custom code that directly calls the setter methods. For UI integration, use a custom process button (e.g., "Create Release from Blanket") that invokes your release order creation logic.

Best Practices Summary

Practice Recommendation
Voiding linked orders Always void through voidIt(); never delete directly
Copying orders Clear Link_Order_ID and Link_OrderLine_ID on copies
Blanket order tracking Use SQL queries on C_OrderLine.Link_OrderLine_ID for consumption status
Contract pricing Enforce via ModelValidator on TYPE_BEFORE_NEW / TYPE_BEFORE_CHANGE
Drop-ship setup Always set both Link_Order_ID and Link_OrderLine_ID
Counter documents Maintain org-to-BPartner mappings and MDocTypeCounter configuration
Concurrency control Validate remaining blanket balance at TIMING_BEFORE_COMPLETE
Contract expiration Use DatePromised on the blanket PO and validate in release creation
Audit trail Use Description field on release lines to note the blanket reference

Summary

This lesson covered iDempiere's contract-based procurement mechanisms, from the fundamental link fields through advanced patterns like blanket orders and drop-ship flows. Here are the key takeaways:

  • Link_Order_ID (on C_Order) and Link_OrderLine_ID (on C_OrderLine) are the foundation of all linked-order patterns. Both use set_ValueNoCheck in the generated model, making them programmatically-set-only fields defined by constants COLUMNNAME_Link_Order_ID and COLUMNNAME_Link_OrderLine_ID.
  • Counter documents are created by MOrder.createCounterDoc() during completeIt() for inter-organization transactions. They require org-to-BPartner mapping via MOrg.getLinkedC_BPartner_ID() and MBPartner.getAD_OrgBP_ID(), use MDocTypeCounter for document type resolution, and prevent infinite loops by checking getRef_Order_ID() != 0.
  • Drop-ship flows are implemented through the IsDropShip flag plus DropShip_BPartner_ID, DropShip_Location_ID, and DropShip_User_ID fields. The MInOut.createDropShipment() method automatically creates a customer shipment when a drop-ship material receipt is completed, remapping line references from PO lines to SO lines via Link_OrderLine_ID.
  • Blanket purchase orders are modeled as completed orders whose lines define committed quantities and contracted prices. Release orders reference blanket lines via Link_OrderLine_ID, and consumption is tracked by querying SUM(rl.QtyOrdered) from C_OrderLine rl WHERE rl.Link_OrderLine_ID = blanket_line_id.
  • voidIt() in MOrder carefully cleans up linked references at both the header level (getLink_Order_ID() check and clear on the linked order) and the line level (getLink_OrderLine_ID() check and clear on each linked line), preventing orphaned pointers.
  • copyFrom() explicitly clears Link_Order_ID on the copied order and Link_OrderLine_ID on all copied lines, preventing false link propagation.
  • Contract pricing enforcement should be implemented via a ModelValidator that compares release line prices against the blanket line's PriceActual and either auto-corrects or rejects mismatches.
  • Contract monitoring uses DatePromised as the expiration date and SQL queries joining C_OrderLine (blanket lines) with C_OrderLine (release lines via Link_OrderLine_ID) to calculate utilization, remaining balances, and expiration warnings.
  • Concurrency control for blanket order releases should include a final validation at TIMING_BEFORE_COMPLETE to catch over-commitments that passed initial validation due to concurrent transactions.

You Missed