Purchase Contracts & Blanket Orders
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:
- Not itself a counter document: If
getRef_Order_ID() != 0, this order is already a counter doc and should not spawn another one. - 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).
- Business Partner linked to Organization: The business partner on the order must have an
AD_OrgBP_IDset, identifying which organization they represent. - Valid counter document type: Either a direct
MDocTypeCountermapping 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:
- Quantity Check: The release quantity must not exceed the remaining blanket balance.
- Price Consistency: The release order line price should match the blanket contract price (or a documented override must be provided).
- Contract Expiration: The release order's
DateOrderedmust fall within the blanket order's contract period (beforeDatePromised). - Product Match: The release line's product must match the blanket line's product.
- 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
- Identify the blanket PO and required products/quantities.
- Create a new purchase order with the same vendor, warehouse, and price list as the blanket PO.
- Add release lines for each product needed, setting
Link_OrderLine_IDto the corresponding blanket line. - Validate remaining blanket balance to ensure the release does not exceed the committed quantity.
- Validate contract pricing by matching prices to the blanket line.
- Validate contract expiration by checking the release date against
DatePromised. - 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 (onC_OrderLine) are the foundation of all linked-order patterns. Both useset_ValueNoCheckin the generated model, making them programmatically-set-only fields defined by constantsCOLUMNNAME_Link_Order_IDandCOLUMNNAME_Link_OrderLine_ID. - Counter documents are created by
MOrder.createCounterDoc()duringcompleteIt()for inter-organization transactions. They require org-to-BPartner mapping viaMOrg.getLinkedC_BPartner_ID()andMBPartner.getAD_OrgBP_ID(), useMDocTypeCounterfor document type resolution, and prevent infinite loops by checkinggetRef_Order_ID() != 0. - Drop-ship flows are implemented through the
IsDropShipflag plusDropShip_BPartner_ID,DropShip_Location_ID, andDropShip_User_IDfields. TheMInOut.createDropShipment()method automatically creates a customer shipment when a drop-ship material receipt is completed, remapping line references from PO lines to SO lines viaLink_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 queryingSUM(rl.QtyOrdered)fromC_OrderLine rl WHERE rl.Link_OrderLine_ID = blanket_line_id. - voidIt() in
MOrdercarefully 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_IDon the copied order andLink_OrderLine_IDon all copied lines, preventing false link propagation. - Contract pricing enforcement should be implemented via a
ModelValidatorthat compares release line prices against the blanket line'sPriceActualand either auto-corrects or rejects mismatches. - Contract monitoring uses
DatePromisedas the expiration date and SQL queries joiningC_OrderLine(blanket lines) withC_OrderLine(release lines viaLink_OrderLine_ID) to calculate utilization, remaining balances, and expiration warnings. - Concurrency control for blanket order releases should include a final validation at
TIMING_BEFORE_COMPLETEto catch over-commitments that passed initial validation due to concurrent transactions.