Procurement Overview & Requisitions
Overview
- What you’ll learn:
- The complete Procure-to-Pay lifecycle in iDempiere: Requisition, Purchase Order, Material Receipt, Invoice Verification, and Payment
- How each stage maps to a specific model class and DocBaseType constant (POR, POO, MMR, API, APP)
- How to create and configure MRequisition documents, including initial defaults, priority rules, and the DocAction workflow
- How MRequisitionLine manages line-level detail including pricing, vendor overrides, and PO linking
- How the RequisitionPOCreate process automatically converts completed requisitions into Purchase Orders with vendor resolution, order consolidation, and quantity aggregation
- Prerequisites: Familiarity with iDempiere document processing, the DocAction interface, and basic product/warehouse concepts (Lessons 1-23)
- Estimated reading time: 25 minutes
Introduction: The Procure-to-Pay Lifecycle
Procurement is the backbone of any organization’s supply chain. It encompasses everything from identifying a need for materials or services through to paying the vendor who supplies them. In iDempiere, this entire lifecycle is modeled as a series of interconnected documents, each with its own model class, document base type, and processing logic.
The Procure-to-Pay (P2P) lifecycle in iDempiere follows five core stages:
- Requisition — An internal request for goods or services. A department user creates a requisition specifying what is needed, how much, and by when. This is the starting point of the procurement cycle.
- Purchase Order (PO) — A formal commitment to a vendor. Once a requisition is approved and completed, it can be converted into a Purchase Order directed at a specific vendor at agreed-upon prices.
- Material Receipt — Physical receipt of goods into the warehouse. When the vendor delivers the ordered items, a Material Receipt document records the inventory increase.
- Invoice Verification (AP Invoice) — The vendor’s invoice is matched against the PO and receipt. Three-way matching ensures you pay only for what was ordered and received.
- Payment — The final step: issuing payment to the vendor, closing the payable created by the invoice.
Stage-to-Model Mapping
Each stage in the P2P lifecycle corresponds to a specific model class and document base type constant defined in X_C_DocType:
| Stage | Model Class | DocBaseType Constant | Value | Description |
|---|---|---|---|---|
| Requisition | MRequisition |
DOCBASETYPE_PurchaseRequisition |
“POR” | Internal purchase request |
| Purchase Order | MOrder (IsSOTrx=false) |
DOCBASETYPE_PurchaseOrder |
“POO” | Formal order to vendor |
| Material Receipt | MInOut (IsSOTrx=false) |
DOCBASETYPE_MaterialReceipt |
“MMR” | Goods received into warehouse |
| AP Invoice | MInvoice (IsSOTrx=false) |
DOCBASETYPE_APInvoice |
“API” | Vendor invoice for matching |
| AP Payment | MPayment (IsReceipt=false) |
DOCBASETYPE_APPayment |
“APP” | Payment to vendor |
These constants are defined in org.compiere.model.X_C_DocType:
// From X_C_DocType.java
public static final String DOCBASETYPE_PurchaseRequisition = "POR";
public static final String DOCBASETYPE_PurchaseOrder = "POO";
public static final String DOCBASETYPE_MaterialReceipt = "MMR";
public static final String DOCBASETYPE_APInvoice = "API";
public static final String DOCBASETYPE_APPayment = "APP";
The DocBaseType is critical because it drives period validation, document numbering sequences, accounting schemas, and workflow routing. When MPeriod.testPeriodOpen() is called, it checks whether the accounting period is open for the specific DocBaseType — a period could be open for Purchase Requisitions but closed for AP Invoices.
Document Flow Visualization
The documents chain together through foreign key references:
Requisition (M_Requisition / M_RequisitionLine)
|
| C_OrderLine_ID (link set by RequisitionPOCreate)
v
Purchase Order (C_Order / C_OrderLine)
|
| C_OrderLine_ID (reference on receipt line)
v
Material Receipt (M_InOut / M_InOutLine)
|
| M_InOutLine_ID (matched via M_MatchPO, M_MatchInv)
v
AP Invoice (C_Invoice / C_InvoiceLine)
|
| C_Invoice_ID (referenced on payment allocation)
v
AP Payment (C_Payment / C_AllocationLine)
This lesson focuses on the first stage — Requisitions — and the process that bridges the first two stages: RequisitionPOCreate.
MRequisition Deep Dive
The MRequisition class (org.compiere.model.MRequisition) represents a purchase requisition document. It extends X_M_Requisition (the generated base class for the M_Requisition table) and implements the DocAction interface, giving it the full document lifecycle: Draft, In Progress, Complete, Close, Void.
Class Hierarchy
public class MRequisition extends X_M_Requisition implements DocAction
//
// Inheritance chain:
// MRequisition
// -> X_M_Requisition (generated model)
// -> PO (persistence layer)
// -> Object
//
// Implements: DocAction (document workflow interface)
Constructors
MRequisition provides three constructors following the standard iDempiere pattern:
// Standard constructor — creates new or loads existing by ID
public MRequisition(Properties ctx, int M_Requisition_ID, String trxName)
// UUID-based constructor — loads by UUID string
public MRequisition(Properties ctx, String M_Requisition_UU, String trxName)
// Load constructor — creates from a ResultSet row
public MRequisition(Properties ctx, ResultSet rs, String trxName)
When creating a new record (ID = 0 or empty UUID), the constructor calls setInitialDefaults() to establish sensible starting values.
Initial Defaults
The setInitialDefaults() method sets the following values on a new requisition:
private void setInitialDefaults() {
setDateDoc(new Timestamp(System.currentTimeMillis()));
setDateRequired(new Timestamp(System.currentTimeMillis()));
setDocAction(DocAction.ACTION_Complete); // "CO"
setDocStatus(DocAction.STATUS_Drafted); // "DR"
setPriorityRule(PRIORITYRULE_Medium); // "5"
setTotalLines(Env.ZERO);
setIsApproved(false);
setPosted(false);
setProcessed(false);
}
Let us examine each default:
| Field | Default Value | Explanation |
|---|---|---|
| DateDoc | Current timestamp | Document date defaults to “now” |
| DateRequired | Current timestamp | Required date also defaults to “now” — the user should override this |
| DocAction | “CO” (Complete) | Next suggested action is to complete the document |
| DocStatus | “DR” (Drafted) | New documents start in Draft status |
| PriorityRule | “5” (Medium) | Default priority is Medium |
| TotalLines | Env.ZERO (BigDecimal.ZERO) | No line amounts yet |
| IsApproved | false | Not yet approved |
| Posted | false | Not yet posted to accounting |
| Processed | false | Not yet processed/completed |
Priority Constants
The PriorityRule field uses string-encoded numeric values. Lower numbers indicate higher priority. These constants are inherited from X_M_Requisition:
// Priority constants (from X_M_Requisition)
PRIORITYRULE_Urgent = "1" // Highest priority
PRIORITYRULE_High = "3"
PRIORITYRULE_Medium = "5" // Default
PRIORITYRULE_Low = "7"
PRIORITYRULE_Minor = "9" // Lowest priority
The string-based encoding is significant: in the RequisitionPOCreate process, the priority filter uses r.PriorityRule >= ?, meaning it selects requisitions with priority equal to or lower (higher number) than the specified value. If you filter by “3” (High), you get High, Medium, Low, and Minor — but not Urgent. This is a deliberate design: filtering by High means “High priority and below.”
Key Methods Overview
| Method | Return Type | Purpose |
|---|---|---|
getLines() |
MRequisitionLine[] |
Returns all requisition lines, ordered by Line number then ID. Results are cached in m_lines. |
processIt(String) |
boolean |
Entry point for document processing. Delegates to DocumentEngine. |
prepareIt() |
String |
Validates the document and returns new status (InProgress or Invalid). |
completeIt() |
String |
Completes the document: approves, sets Processed=true. |
closeIt() |
boolean |
Closes the document, adjusting quantities for partially-ordered lines. |
voidIt() |
boolean |
Voids the document — delegates to closeIt(). |
approveIt() |
boolean |
Sets IsApproved = true. |
rejectIt() |
boolean |
Sets IsApproved = false. |
reActivateIt() |
boolean |
Attempts reactivation — delegates to reverseCorrectIt() which always returns false. |
reverseCorrectIt() |
boolean |
Not implemented — always returns false. |
reverseAccrualIt() |
boolean |
Not implemented — always returns false. |
getDoc_User_ID() |
int |
Returns getAD_User_ID() — the document owner for workflow routing. |
getC_Currency_ID() |
int |
Looks up the currency from the associated price list via MPriceList. |
getApprovalAmt() |
BigDecimal |
Returns getTotalLines() — used for approval hierarchy thresholds. |
The processIt() Method and DocumentEngine
The processIt() method is the single entry point for all document state transitions. It does not contain the workflow logic directly — instead, it delegates to DocumentEngine:
public boolean processIt(String processAction) {
m_processMsg = null;
DocumentEngine engine = new DocumentEngine(this, getDocStatus());
return engine.processIt(processAction, getDocAction());
}
The DocumentEngine is a state machine that maps the current DocStatus and the requested processAction to the appropriate method call (prepareIt(), completeIt(), voidIt(), etc.). This pattern is used by every DocAction-implementing class in iDempiere, ensuring consistent document workflow behavior across the entire system.
prepareIt() — Validation Logic
The prepareIt() method performs comprehensive validation before allowing a requisition to proceed to completion. Here is the complete logic:
public String prepareIt() {
if (log.isLoggable(Level.INFO)) log.info(toString());
// Fire BEFORE_PREPARE model validator event
m_processMsg = ModelValidationEngine.get().fireDocValidate(
this, ModelValidator.TIMING_BEFORE_PREPARE);
if (m_processMsg != null)
return DocAction.STATUS_Invalid;
MRequisitionLine[] lines = getLines();
// Validation 1: Required header fields
if (getAD_User_ID() == 0
|| getM_PriceList_ID() == 0
|| getM_Warehouse_ID() == 0)
{
return DocAction.STATUS_Invalid;
}
// Validation 2: At least one line required
if (lines.length == 0)
{
throw new AdempiereException("@NoLines@");
}
// Validation 3: Accounting period must be open
MPeriod.testPeriodOpen(getCtx(), getDateDoc(),
MDocType.DOCBASETYPE_PurchaseRequisition, getAD_Org_ID());
// Recalculate line amounts and total
int precision = MPriceList.getStandardPrecision(
getCtx(), getM_PriceList_ID());
BigDecimal totalLines = Env.ZERO;
for (int i = 0; i < lines.length; i++) {
MRequisitionLine line = lines[i];
BigDecimal lineNet = line.getQty().multiply(line.getPriceActual());
lineNet = lineNet.setScale(precision, RoundingMode.HALF_UP);
if (lineNet.compareTo(line.getLineNetAmt()) != 0) {
line.setLineNetAmt(lineNet);
line.saveEx();
}
totalLines = totalLines.add(line.getLineNetAmt());
}
if (totalLines.compareTo(getTotalLines()) != 0) {
setTotalLines(totalLines);
saveEx();
}
// Fire AFTER_PREPARE model validator event
m_processMsg = ModelValidationEngine.get().fireDocValidate(
this, ModelValidator.TIMING_AFTER_PREPARE);
if (m_processMsg != null)
return DocAction.STATUS_Invalid;
m_justPrepared = true;
return DocAction.STATUS_InProgress; // "IP"
}
The validation sequence is deliberate:
- Model Validator (BEFORE_PREPARE) — Allows plugins to inject custom validation logic before standard checks run. If the validator returns a message, preparation fails immediately.
- Header field validation — Three fields are mandatory:
AD_User_ID(who is requesting),M_PriceList_ID(for pricing and currency), andM_Warehouse_ID(where the goods should be delivered). If any is zero, the document is Invalid. Note that no exception is thrown here — the method simply returnsSTATUS_Invalid, which means the UI will show a generic error. This is a known usability limitation. - Line count check — At least one line is required. Unlike the header check, this throws an
AdempiereExceptionwith the translatable message key@NoLines@, providing a clear user-facing error. - Period validation —
MPeriod.testPeriodOpen()throws an exception if the accounting period is not open for document base type "POR" (Purchase Requisition) in the requisition's organization. This prevents backdating documents into closed periods. - Amount recalculation — Each line's
LineNetAmtis recalculated asQty * PriceActual, rounded to the price list's standard precision. The header'sTotalLinesis recalculated as the sum of all line net amounts. Any discrepancies are corrected and saved — this ensures the totals are always consistent, even if lines were modified directly in the database. - Model Validator (AFTER_PREPARE) — A second hook for plugins to validate after standard preparation is complete.
completeIt() — Completion Logic
The completeIt() method finalizes the requisition:
public String completeIt() {
// Re-check: if not just prepared, run prepareIt() again
if (!m_justPrepared) {
String status = prepareIt();
m_justPrepared = false;
if (!DocAction.STATUS_InProgress.equals(status))
return status;
}
// Set definite document number (if doc type configured for it)
setDefiniteDocumentNo();
// Fire BEFORE_COMPLETE model validator
m_processMsg = ModelValidationEngine.get().fireDocValidate(
this, ModelValidator.TIMING_BEFORE_COMPLETE);
if (m_processMsg != null)
return DocAction.STATUS_Invalid;
// Implicit approval — if not yet approved, approve now
if (!isApproved())
approveIt();
if (log.isLoggable(Level.INFO)) log.info(toString());
// Fire AFTER_COMPLETE model validator
String valid = ModelValidationEngine.get().fireDocValidate(
this, ModelValidator.TIMING_AFTER_COMPLETE);
if (valid != null) {
m_processMsg = valid;
return DocAction.STATUS_Invalid;
}
// Mark as processed and set next action to Close
setProcessed(true);
setDocAction(ACTION_Close);
return DocAction.STATUS_Completed; // "CO"
}
Key behaviors in completeIt():
- Re-preparation safety net: If
prepareIt()was not just called (them_justPreparedflag), the system re-runs it. This handles the case where a user directly triggers completion without first preparing — theDocumentEnginenormally callsprepareIt()beforecompleteIt(), but this guard ensures correctness even if the methods are called out of order. - Definite document number: The
setDefiniteDocumentNo()method checks the document type settings. IfisOverwriteDateOnComplete()is true, the document date is updated to today (and the period is re-validated). IfisOverwriteSeqOnComplete()is true, a new document number is generated from the definite sequence — this is the "two-sequence" pattern where drafts get a preliminary number and completed documents get a final number. - Implicit approval: If the requisition has not been explicitly approved (via a workflow approval step or manual action),
completeIt()automatically approves it. This means that in a basic setup without approval workflows, completing a requisition also approves it. - Post-completion state:
Processed = trueprevents further editing.DocAction = Closemeans the next available action after completion is to close the document.
closeIt() — Closing with Quantity Adjustment
The closeIt() method handles the important task of reconciling requisitioned quantities against what was actually ordered. This is where partially-fulfilled requisitions are finalized:
public boolean closeIt() {
if (log.isLoggable(Level.INFO)) log.info("closeIt - " + toString());
// Fire BEFORE_CLOSE model validator
m_processMsg = ModelValidationEngine.get().fireDocValidate(
this, ModelValidator.TIMING_BEFORE_CLOSE);
if (m_processMsg != null)
return false;
// Adjust quantities for each line
MRequisitionLine[] lines = getLines();
BigDecimal totalLines = Env.ZERO;
for (int i = 0; i < lines.length; i++) {
MRequisitionLine line = lines[i];
BigDecimal finalQty = line.getQty();
if (line.getC_OrderLine_ID() == 0)
// Not linked to PO — set quantity to zero
finalQty = Env.ZERO;
else {
// Linked to PO — use the PO line's ordered quantity
MOrderLine ol = new MOrderLine(
getCtx(), line.getC_OrderLine_ID(), get_TrxName());
finalQty = ol.getQtyOrdered();
}
// If final qty differs from original, record the change
if (finalQty.compareTo(line.getQty()) != 0) {
String description = line.getDescription();
if (description == null)
description = "";
description += " [" + line.getQty() + "]";
line.setDescription(description);
line.setQty(finalQty);
line.setLineNetAmt();
line.saveEx();
}
totalLines = totalLines.add(line.getLineNetAmt());
}
if (totalLines.compareTo(getTotalLines()) != 0) {
setTotalLines(totalLines);
saveEx();
}
// Fire AFTER_CLOSE model validator
m_processMsg = ModelValidationEngine.get().fireDocValidate(
this, ModelValidator.TIMING_AFTER_CLOSE);
if (m_processMsg != null)
return false;
return true;
}
The close logic for each line depends on its PO linkage:
- Unlinked lines (
C_OrderLine_ID == 0): These lines were never converted to a Purchase Order. The quantity is set to zero, effectively canceling the requirement. - Linked lines (
C_OrderLine_ID != 0): The system reads the actual PO line'sQtyOrdered. This may differ from the original requisition quantity if the buyer adjusted quantities on the PO.
When the final quantity differs from the original, the system appends the original quantity in square brackets to the description field (e.g., " [100]"). This provides an audit trail — you can always see what was originally requested versus what was finally ordered. The LineNetAmt and header TotalLines are recalculated to reflect the adjusted quantities.
voidIt() — Delegates to closeIt()
Voiding a requisition is functionally identical to closing it:
public boolean voidIt() {
if (log.isLoggable(Level.INFO)) log.info("voidIt - " + toString());
// Fire BEFORE_VOID model validator
m_processMsg = ModelValidationEngine.get().fireDocValidate(
this, ModelValidator.TIMING_BEFORE_VOID);
if (m_processMsg != null)
return false;
if (!closeIt())
return false;
// Fire AFTER_VOID model validator
m_processMsg = ModelValidationEngine.get().fireDocValidate(
this, ModelValidator.TIMING_AFTER_VOID);
if (m_processMsg != null)
return false;
return true;
}
The rationale: a requisition is a non-financial, non-inventory document. It does not create accounting entries or inventory transactions. Therefore, voiding it does not require a reversal — simply closing (zeroing out unlinked lines) is sufficient. The void fires its own model validator events (BEFORE_VOID, AFTER_VOID), so plugins can still differentiate between a void and a close.
reverseCorrectIt() and reverseAccrualIt() — Not Implemented
Both reversal methods always return false:
public boolean reverseCorrectIt() {
// Fires model validators, but always returns false
return false;
}
public boolean reverseAccrualIt() {
// Fires model validators, but always returns false
return false;
}
Requisitions do not support reversal because they generate no accounting or inventory transactions. There is nothing to reverse. This also means that reActivateIt() will always fail, because its implementation delegates to reverseCorrectIt():
public boolean reActivateIt() {
// ...
if (!reverseCorrectIt()) // Always returns false
return false;
// This code is never reached
return true;
}
Important implication: Once a requisition is completed, it cannot be reactivated through the standard DocAction mechanism. To modify a completed requisition, you would need to void it and create a new one, or implement a custom model validator that overrides this behavior.
beforeSave() and beforeDelete()
The MRequisition class has two persistence hooks:
@Override
protected boolean beforeSave(boolean newRecord) {
// Set default price list if not specified
if (getM_PriceList_ID() == 0)
setM_PriceList_ID();
return true;
}
@Override
protected boolean beforeDelete() {
// Delete all lines before deleting the header
for (MRequisitionLine line : getLines()) {
line.deleteEx(true);
}
return true;
}
The setM_PriceList_ID() no-argument method looks up the default purchase price list. It first tries MPriceList.getDefault(ctx, false) (purchase price list), falling back to MPriceList.getDefault(ctx, true) (sales price list). This ensures every requisition has a price list for currency determination and line pricing.
The beforeDelete() method cascades deletion to all lines. Without this, deleting a requisition header would fail due to foreign key constraints on the line table.
Helper Methods for Workflow and Approval
Three methods support iDempiere's approval workflow engine:
// Returns the requesting user — used for workflow routing
public int getDoc_User_ID() {
return getAD_User_ID();
}
// Returns currency from the price list — used for multi-currency approval limits
public int getC_Currency_ID() {
MPriceList pl = MPriceList.get(getCtx(), getM_PriceList_ID(), get_TrxName());
return pl.getC_Currency_ID();
}
// Returns TotalLines — compared against approval thresholds
public BigDecimal getApprovalAmt() {
return getTotalLines();
}
These methods are called by the workflow engine when routing a requisition for approval. For example, if an approval rule states "Requisitions over $5,000 require manager approval," the engine calls getApprovalAmt() to get the total, getC_Currency_ID() for currency conversion, and getDoc_User_ID() to determine the requestor's supervisor.
MRequisitionLine Deep Dive
The MRequisitionLine class (org.compiere.model.MRequisitionLine) represents a single line item on a requisition. Each line specifies a product or charge, a quantity, a price, and optionally a preferred vendor. Lines are linked to Purchase Order lines after the RequisitionPOCreate process runs.
Static Methods for PO Linking
MRequisitionLine provides four static methods that manage the relationship between requisition lines and Purchase Order lines:
// Find all requisition lines linked to a specific Purchase Order
public static MRequisitionLine[] forC_Order_ID(
Properties ctx, int C_Order_ID, String trxName)
// Find all requisition lines linked to a specific PO line
public static MRequisitionLine[] forC_OrderLine_ID(
Properties ctx, int C_OrderLine_ID, String trxName)
// Unlink all requisition lines from a Purchase Order
// (sets C_OrderLine_ID = 0 for each)
public static void unlinkC_Order_ID(
Properties ctx, int C_Order_ID, String trxName)
// Unlink all requisition lines from a specific PO line
public static void unlinkC_OrderLine_ID(
Properties ctx, int C_OrderLine_ID, String trxName)
The forC_Order_ID() method uses an EXISTS subquery to find requisition lines whose C_OrderLine_ID belongs to order lines of the given order:
final String whereClause = "EXISTS (SELECT 1 FROM C_OrderLine ol"
+ " WHERE ol.C_OrderLine_ID=M_RequisitionLine.C_OrderLine_ID"
+ " AND ol.C_Order_ID=?)";
List<MRequisitionLine> list = new Query(ctx,
I_M_RequisitionLine.Table_Name, whereClause, trxName)
.setParameters(C_Order_ID)
.list();
The unlink methods are called when a Purchase Order is voided or deleted — they remove the link so the requisition lines become eligible for re-processing by RequisitionPOCreate.
Constructors and Parent Constructor
In addition to the standard constructors, MRequisitionLine has a parent constructor that simplifies creating lines for a specific requisition:
// Parent constructor — creates a new line for the given requisition
public MRequisitionLine(MRequisition req) {
this(req.getCtx(), 0, req.get_TrxName());
setClientOrg(req); // Inherit client and org
setM_Requisition_ID(req.getM_Requisition_ID()); // Link to parent
m_M_PriceList_ID = req.getM_PriceList_ID(); // Cache price list for pricing
m_parent = req; // Cache parent reference
}
This constructor inherits the client, organization, and price list from the parent requisition, saving the developer from setting these fields manually.
Initial Defaults
New requisition lines start with these defaults:
private void setInitialDefaults() {
setLine(0); // Will be auto-incremented by beforeSave()
setLineNetAmt(Env.ZERO); // 0.00
setPriceActual(Env.ZERO); // 0.00
setQty(Env.ONE); // 1
}
The Line number is set to 0 initially. The beforeSave() method detects this and auto-assigns the next available line number using a MAX+10 query — this is the standard iDempiere pattern of numbering lines in increments of 10 (10, 20, 30...) to allow inserting lines between existing ones.
Key Fields
| Field | Type | Description |
|---|---|---|
| M_Requisition_ID | int (FK) | Parent requisition — every line belongs to exactly one requisition |
| Line | int | Line sequence number (10, 20, 30...). Auto-assigned if zero. |
| M_Product_ID | int (FK) | Product being requested. Mutually exclusive with C_Charge_ID. |
| C_Charge_ID | int (FK) | Charge (expense category). Mutually exclusive with M_Product_ID. |
| Qty | BigDecimal | Requested quantity. Defaults to 1. |
| PriceActual | BigDecimal | Unit price. Auto-set from charge amount or product price list. |
| LineNetAmt | BigDecimal | Line total = Qty * PriceActual. Auto-calculated. |
| C_BPartner_ID | int (FK) | Per-line vendor override. If set, RequisitionPOCreate uses this vendor instead of the product's default vendor. |
| C_OrderLine_ID | int (FK) | Link to PO line. Set by RequisitionPOCreate when a Purchase Order is generated. Zero means "not yet ordered." |
| C_UOM_ID | int (FK) | Unit of measure. Defaults to the product's UOM if not specified. |
| M_AttributeSetInstance_ID | int (FK) | Specific lot/serial/attribute combination. Cleared if line is a charge. |
| Description | String | Free-text description. Used by closeIt() to record original quantities. |
Pricing Logic
MRequisitionLine has two setPrice() methods that handle automatic pricing:
// Set price from charge amount or product price list
public void setPrice() {
// If this is a charge line, use the charge's predefined amount
if (getC_Charge_ID() != 0) {
MCharge charge = MCharge.get(getCtx(), getC_Charge_ID());
setPriceActual(charge.getChargeAmt());
}
// If no product, nothing more to do
if (getM_Product_ID() == 0)
return;
// Resolve price list (from cached value or parent requisition)
if (m_M_PriceList_ID == 0)
m_M_PriceList_ID = getParent().getM_PriceList_ID();
if (m_M_PriceList_ID == 0) {
throw new AdempiereException("PriceList unknown!");
}
setPrice(m_M_PriceList_ID);
}
// Set price using a specific price list via IProductPricing
public void setPrice(int M_PriceList_ID) {
if (getM_Product_ID() == 0)
return;
IProductPricing pp = Core.getProductPricing();
pp.setRequisitionLine(this, get_TrxName());
pp.setM_PriceList_ID(M_PriceList_ID);
setPriceActual(pp.getPriceStd());
}
The pricing resolution follows this priority:
- Charge amount: If the line has a charge, the charge's predefined amount is used regardless.
- Product price list: For product lines, the system uses iDempiere's
IProductPricingservice to look up the standard price (getPriceStd()) from the requisition's price list. This respects price list versions, date effectivity, and any custom pricing plugins.
The setLineNetAmt() method simply multiplies quantity by price:
public void setLineNetAmt() {
BigDecimal lineNetAmt = getQty().multiply(getPriceActual());
super.setLineNetAmt(lineNetAmt);
}
beforeSave() — Automatic Field Management
The beforeSave() method on MRequisitionLine performs several automatic adjustments every time a line is saved:
@Override
protected boolean beforeSave(boolean newRecord) {
// 1. Prevent adding lines to a completed requisition
if (newRecord && getParent().isProcessed()) {
log.saveError("ParentComplete",
Msg.translate(getCtx(), "M_Requisition_ID"));
return false;
}
// 2. Auto-increment line number if zero
if (getLine() == 0) {
String sql = "SELECT COALESCE(MAX(Line),0)+10 "
+ "FROM M_RequisitionLine WHERE M_Requisition_ID=?";
int ii = DB.getSQLValueEx(get_TrxName(), sql,
getM_Requisition_ID());
setLine(ii);
}
// 3. Clear charge if product is set (mutually exclusive)
if (getM_Product_ID() != 0 && getC_Charge_ID() != 0)
setC_Charge_ID(0);
// 4. Clear attribute set instance if charge is set
if (getM_AttributeSetInstance_ID() != 0 && getC_Charge_ID() != 0)
setM_AttributeSetInstance_ID(0);
// 5. Default UOM from product
if (getM_Product_ID() > 0 && getC_UOM_ID() <= 0) {
setC_UOM_ID(getM_Product().getC_UOM_ID());
}
// 6. Auto-set price if zero
if (getPriceActual().signum() == 0)
setPrice();
// 7. Always recalculate line net amount
setLineNetAmt();
// 8. Enforce charge-or-product-mandatory rule
MDocType dt = MDocType.get(getParent().getC_DocType_ID());
if (dt.isChargeOrProductMandatory()) {
if (getC_Charge_ID() == 0 && getM_Product_ID() == 0
&& (getPriceActual().signum() != 0
|| getQty().signum() != 0)) {
log.saveError("FillMandatory",
Msg.translate(getCtx(), "ChargeOrProductMandatory"));
return false;
}
}
return true;
}
This method ensures data integrity across several dimensions:
- Immutability of completed documents: You cannot add lines to a processed requisition. This is enforced at the model level, not just the UI level.
- Line numbering: Auto-assigns the next available line number (MAX+10) so lines are always in a predictable sequence.
- Mutual exclusivity: A line is either for a product or a charge, never both. If both are set, the charge is silently cleared. Similarly, attribute set instances are meaningless for charges and are cleared.
- UOM defaulting: If no UOM is specified but a product is, the product's default UOM is used.
- Price and amount consistency: Price is auto-looked-up if zero, and the line net amount is always recalculated to ensure
LineNetAmt = Qty * PriceActual.
afterSave() and afterDelete() — Header Update
Both afterSave() and afterDelete() call updateHeader(), which recalculates the parent requisition's TotalLines using a direct SQL update:
private boolean updateHeader() {
String sql = "UPDATE M_Requisition r"
+ " SET TotalLines="
+ "(SELECT COALESCE(SUM(LineNetAmt),0) "
+ " FROM M_RequisitionLine rl"
+ " WHERE r.M_Requisition_ID=rl.M_Requisition_ID)"
+ " WHERE M_Requisition_ID=?";
int no = DB.executeUpdateEx(sql,
new Object[]{getM_Requisition_ID()}, get_TrxName());
m_parent = null; // Clear cached parent
return no == 1;
}
This ensures the requisition header total is always synchronized with its lines, whether a line is added, modified, or deleted. The direct SQL approach is efficient — it avoids loading and iterating over all lines in Java, performing the aggregation in a single database operation.
RequisitionPOCreate — Automated PO Generation
The RequisitionPOCreate class (org.compiere.process.RequisitionPOCreate) is a server process that converts completed requisition lines into Purchase Orders. It extends SvrProcess, which means it can be run from the iDempiere process framework — either triggered manually from the UI or scheduled as a batch job.
Process Parameters
The process accepts 13 parameters that control which requisition lines to process and how to consolidate them into Purchase Orders:
| Parameter | Type | Description |
|---|---|---|
| p_AD_Org_ID | int | Filter by organization |
| p_M_Warehouse_ID | int | Filter by warehouse |
| p_DateDoc_From | Timestamp | Requisition document date range start |
| p_DateDoc_To | Timestamp | Requisition document date range end |
| p_DateRequired_From | Timestamp | Required date range start |
| p_DateRequired_To | Timestamp | Required date range end |
| p_PriorityRule | String | Minimum priority filter (e.g., "3" means High and below) |
| p_AD_User_ID | int | Filter by requesting user |
| p_M_Product_ID | int | Filter by specific product |
| p_M_Product_Category_ID | int | Filter by product category (if no specific product) |
| p_C_BP_Group_ID | int | Filter by business partner group (vendor group) |
| p_M_Requisition_ID | int | Process a single specific requisition |
| p_ConsolidateDocument | boolean | If true, combines lines from multiple requisitions into fewer POs |
All parameters are optional. With no parameters set, the process would attempt to create POs for all completed requisition lines that are not yet linked to a Purchase Order.
doIt() — Main Processing Logic
The doIt() method has two paths depending on whether a specific requisition is selected:
Path 1: Single Requisition
if (p_M_Requisition_ID != 0) {
MRequisition req = new MRequisition(
getCtx(), p_M_Requisition_ID, get_TrxName());
// Must be Completed status
if (!MRequisition.DOCSTATUS_Completed.equals(req.getDocStatus())) {
throw new AdempiereUserError("@DocStatus@ = " + req.getDocStatus());
}
// Process only unlinked lines
MRequisitionLine[] lines = req.getLines();
for (int i = 0; i < lines.length; i++) {
if (lines[i].getC_OrderLine_ID() == 0) {
process(lines[i]);
}
}
closeOrder();
return "";
}
When a specific requisition is provided, the process loads its lines, skips any already linked to PO lines (C_OrderLine_ID != 0), and processes the rest. This allows partial re-processing — if some lines from a previous run failed, you can re-run the process for just that requisition.
Path 2: Batch Processing
The batch path builds a dynamic WHERE clause from all the filter parameters:
ArrayList<Object> params = new ArrayList<Object>();
StringBuilder whereClause = new StringBuilder("C_OrderLine_ID IS NULL");
// Organization filter
if (p_AD_Org_ID > 0) {
whereClause.append(" AND AD_Org_ID=?");
params.add(p_AD_Org_ID);
}
// Product filter (specific product or category)
if (p_M_Product_ID > 0) {
whereClause.append(" AND M_Product_ID=?");
params.add(p_M_Product_ID);
} else if (p_M_Product_Category_ID > 0) {
whereClause.append(" AND EXISTS (SELECT 1 FROM M_Product p"
+ " WHERE M_RequisitionLine.M_Product_ID=p.M_Product_ID"
+ " AND p.M_Product_Category_ID=?)");
params.add(p_M_Product_Category_ID);
}
// Business partner group filter
if (p_C_BP_Group_ID > 0) {
whereClause.append(" AND (")
.append("M_RequisitionLine.C_BPartner_ID IS NULL")
.append(" OR EXISTS (SELECT 1 FROM C_BPartner bp")
.append(" WHERE M_RequisitionLine.C_BPartner_ID=bp.C_BPartner_ID")
.append(" AND bp.C_BP_Group_ID=?)")
.append(")");
params.add(p_C_BP_Group_ID);
}
// Requisition header filters (status, warehouse, dates, priority, user)
whereClause.append(" AND EXISTS (SELECT 1 FROM M_Requisition r"
+ " WHERE M_RequisitionLine.M_Requisition_ID=r.M_Requisition_ID"
+ " AND r.DocStatus=?");
params.add(MRequisition.DOCSTATUS_Completed);
// ... additional header filters for warehouse, dates, priority, user ...
The query always starts with C_OrderLine_ID IS NULL — this is the fundamental filter that selects only unlinked requisition lines. The header subquery enforces that only lines from completed requisitions are considered.
Ordering for Consolidation
The ORDER BY clause is critical for the consolidation logic:
StringBuilder orderClause = new StringBuilder();
if (!p_ConsolidateDocument) {
orderClause.append("M_Requisition_ID, ");
}
orderClause.append("(SELECT DateRequired FROM M_Requisition r"
+ " WHERE M_RequisitionLine.M_Requisition_ID=r.M_Requisition_ID),");
orderClause.append("M_Product_ID, C_Charge_ID, M_AttributeSetInstance_ID");
When consolidation is disabled, lines are first grouped by their parent requisition, ensuring one PO per requisition. When consolidation is enabled, this grouping is removed, allowing lines from different requisitions to merge into a single PO (when they share the same vendor, date, and price list).
Within each group, lines are ordered by DateRequired, then by product, charge, and attribute set instance. This ordering ensures that identical products with the same attributes appear consecutively, enabling quantity aggregation.
Vendor Resolution in newLine()
One of the most important aspects of RequisitionPOCreate is determining which vendor should supply each line item. The newLine() method resolves the vendor using a three-level priority system:
private void newLine(MRequisitionLine rLine) throws Exception {
// Save any previous order line
if (m_orderLine != null)
m_orderLine.saveEx();
m_orderLine = null;
MProduct product = MProduct.get(getCtx(), rLine.getM_Product_ID());
// === Vendor Resolution ===
// Priority 1: Line-level vendor override
int C_BPartner_ID = rLine.getC_BPartner_ID();
if (C_BPartner_ID != 0) {
; // Use the line-level vendor
}
// Priority 2: Charge vendor
else if (rLine.getC_Charge_ID() != 0) {
MCharge charge = MCharge.get(getCtx(), rLine.getC_Charge_ID());
C_BPartner_ID = charge.getC_BPartner_ID();
if (C_BPartner_ID == 0)
throw new AdempiereUserError(
"No Vendor for Charge " + charge.getName());
}
// Priority 3: Product's current vendor from M_Product_PO
else {
MProductPO[] ppos = MProductPO.getOfProduct(
getCtx(), product.getM_Product_ID(), null);
for (int i = 0; i < ppos.length; i++) {
if (ppos[i].isCurrentVendor()
&& ppos[i].getC_BPartner_ID() != 0) {
C_BPartner_ID = ppos[i].getC_BPartner_ID();
break;
}
}
// Fallback: first vendor record
if (C_BPartner_ID == 0 && ppos.length > 0)
C_BPartner_ID = ppos[0].getC_BPartner_ID();
// No vendor found at all
if (C_BPartner_ID == 0)
throw new NoVendorForProductException(product.getName());
}
// Check if vendor is in allowed group
if (!isGenerateForVendor(C_BPartner_ID)) {
return; // Skip — m_orderLine remains null
}
// Create new order if vendor/date changed
if (m_order == null
|| m_order.getC_BPartner_ID() != C_BPartner_ID
|| m_order.getDatePromised().compareTo(
rLine.getDateRequired()) != 0)
{
newOrder(rLine, C_BPartner_ID);
}
// Create the order line
m_orderLine = new MOrderLine(m_order);
m_orderLine.setDatePromised(rLine.getDateRequired());
if (product != null) {
m_orderLine.setProduct(product);
m_orderLine.setM_AttributeSetInstance_ID(
rLine.getM_AttributeSetInstance_ID());
} else {
m_orderLine.setC_Charge_ID(rLine.getC_Charge_ID());
m_orderLine.setPriceActual(rLine.getPriceActual());
}
m_orderLine.setAD_Org_ID(rLine.getAD_Org_ID());
// Track for quantity aggregation
m_M_Product_ID = rLine.getM_Product_ID();
m_M_AttributeSetInstance_ID =
rLine.getM_AttributeSetInstance_ID();
m_orderLine.saveEx();
}
The vendor resolution priority in detail:
| Priority | Source | When Used | Failure Behavior |
|---|---|---|---|
| 1 (Highest) | rLine.getC_BPartner_ID() |
Requisition line has a vendor override set by the requestor | N/A — always succeeds if set |
| 2 | MCharge.getC_BPartner_ID() |
Line is a charge (not a product) | Throws AdempiereUserError if charge has no vendor |
| 3 | MProductPO.getOfProduct() |
Product line — looks for current vendor first, then any vendor | Throws NoVendorForProductException if no vendor records exist |
The MProductPO.getOfProduct() method returns vendor records ordered by IsCurrentVendor DESC. The loop first checks for a vendor marked as "current" (isCurrentVendor() = true). If none is found, it falls back to the first vendor record. This means the M_Product_PO table must have at least one record for each product that will be requisitioned.
Order Creation and Consolidation in newOrder()
The newOrder() method creates Purchase Orders and manages a consolidation cache:
private void newOrder(MRequisitionLine rLine, int C_BPartner_ID)
throws Exception
{
if (m_order != null)
closeOrder();
// Load or cache business partner
if (m_bpartner == null || C_BPartner_ID != m_bpartner.get_ID())
m_bpartner = MBPartner.get(getCtx(), C_BPartner_ID);
// Build consolidation key
Timestamp DateRequired = rLine.getDateRequired();
int M_PriceList_ID = rLine.getParent().getM_PriceList_ID();
MultiKey key = new MultiKey(
C_BPartner_ID, DateRequired, M_PriceList_ID);
// Check cache for existing order with same key
m_order = m_cacheOrders.get(key);
if (m_order == null) {
// Create new Purchase Order
m_order = new MOrder(getCtx(), 0, get_TrxName());
m_order.setAD_Org_ID(rLine.getAD_Org_ID());
m_order.setM_Warehouse_ID(
rLine.getParent().getM_Warehouse_ID());
m_order.setDatePromised(DateRequired);
m_order.setIsSOTrx(false); // Purchase transaction
m_order.setC_DocTypeTarget_ID(); // Default PO doc type
m_order.setBPartner(m_bpartner); // Sets address, contact
m_order.setM_PriceList_ID(M_PriceList_ID);
// Set default conversion type if configured
if (MConversionType.getDefault(getAD_Client_ID()) > 0)
m_order.setC_ConversionType_ID(
MConversionType.getDefault(getAD_Client_ID()));
// Non-consolidated: add requisition reference to description
if (!p_ConsolidateDocument) {
m_order.setDescription(
Msg.getElement(getCtx(), "M_Requisition_ID")
+ ": " + rLine.getParent().getDocumentNo());
}
m_order.saveEx();
m_cacheOrders.put(key, m_order); // Cache for reuse
}
m_M_Requisition_ID = rLine.getM_Requisition_ID();
}
The consolidation mechanism uses a HashMap<MultiKey, MOrder> where the key is a composite of three values:
| Key Component | Purpose |
|---|---|
| C_BPartner_ID | Same vendor |
| DateRequired | Same required date |
| M_PriceList_ID | Same price list (and therefore same currency) |
When a new line arrives for a vendor+date+pricelist combination that already has a cached order, the existing order is reused rather than creating a new one. This consolidation significantly reduces the number of POs generated — instead of one PO per requisition line, you get one PO per unique vendor-date-pricelist combination.
The MultiKey class (from Apache Commons Collections) creates a composite hash key from multiple values, making it straightforward to use as a HashMap key.
Quantity Aggregation in process()
The process() method handles line-by-line processing and quantity aggregation:
private void process(MRequisitionLine rLine) throws Exception {
// Skip lines with no product and no charge
if (rLine.getM_Product_ID() == 0 && rLine.getC_Charge_ID() == 0) {
log.warning("Ignored Line" + rLine.getLine()
+ " " + rLine.getDescription()
+ " - " + rLine.getLineNetAmt());
return;
}
// Non-consolidated: close current order when requisition changes
if (!p_ConsolidateDocument
&& rLine.getM_Requisition_ID() != m_M_Requisition_ID)
{
closeOrder();
}
// Determine if we need a new PO line
if (m_orderLine == null
|| rLine.getM_Product_ID() != m_M_Product_ID
|| rLine.getM_AttributeSetInstance_ID()
!= m_M_AttributeSetInstance_ID
|| rLine.getC_Charge_ID() != 0 // Each charge = separate line
|| m_order == null
|| (rLine.getC_BPartner_ID() > 0
&& m_order.getC_BPartner_ID() != rLine.getC_BPartner_ID())
|| m_order.getDatePromised().compareTo(
rLine.getDateRequired()) != 0
)
{
newLine(rLine);
if (m_orderLine == null)
return; // Vendor not valid
}
// Aggregate quantity onto existing order line
m_orderLine.setQty(
m_orderLine.getQtyOrdered().add(rLine.getQty()));
// Link requisition line to PO line
rLine.setC_OrderLine_ID(m_orderLine.getC_OrderLine_ID());
rLine.saveEx();
}
The aggregation logic is nuanced. A new PO line is created when any of these conditions change:
- Product changes: Different product = different PO line.
- Attribute set instance changes: Same product but different lot/serial = different PO line.
- Charge line: Every charge line gets its own PO line (no aggregation for charges).
- Vendor changes: If the requisition line has a vendor override that differs from the current PO's vendor.
- Date changes: Different required date = potentially different PO.
When none of these conditions trigger a new line, the quantity is simply added to the existing PO line. Multiple requisition lines for the same product (from different requisitions or different lines within the same requisition) will be consolidated into a single PO line with the summed quantity.
Crucially, the link is established immediately: rLine.setC_OrderLine_ID(m_orderLine.getC_OrderLine_ID()). This means the requisition line now knows which PO line it was converted to. If the process is re-run, these lines will be skipped because they no longer have C_OrderLine_ID IS NULL.
BP Group Filtering: isGenerateForVendor()
When the p_C_BP_Group_ID parameter is set, the process checks whether the resolved vendor belongs to the specified group:
private boolean isGenerateForVendor(int C_BPartner_ID) {
// No filter set — generate for all vendors
if (p_C_BP_Group_ID <= 0)
return true;
// Check exclusion cache (previously rejected vendors)
if (m_excludedVendors.contains(C_BPartner_ID))
return false;
// Query if vendor is in the specified group
boolean match = new Query(getCtx(), MBPartner.Table_Name,
"C_BPartner_ID=? AND C_BP_Group_ID=?", get_TrxName())
.setParameters(C_BPartner_ID, p_C_BP_Group_ID)
.match();
if (!match)
m_excludedVendors.add(C_BPartner_ID);
return match;
}
private List<Integer> m_excludedVendors = new ArrayList<Integer>();
This method uses a negative cache (m_excludedVendors) to avoid repeated database queries for vendors that have already been rejected. Once a vendor is found to not belong to the required group, they are added to the exclusion list and all subsequent lines for that vendor are silently skipped.
closeOrder() — Finalization and Logging
After all lines are processed, closeOrder() saves the last order line and logs each generated PO:
private void closeOrder() throws Exception {
if (m_orderLine != null)
m_orderLine.saveEx();
if (m_order != null) {
m_order.load(get_TrxName());
String message = Msg.parseTranslation(getCtx(),
"@GeneratedPO@ " + m_order.getDocumentNo());
addBufferLog(0, null, m_order.getGrandTotal(),
message, m_order.get_Table_ID(),
m_order.getC_Order_ID());
}
m_order = null;
m_orderLine = null;
}
The addBufferLog() call creates clickable log entries in the process result dialog. Each entry shows the generated PO's document number and grand total, and clicking it opens the PO directly. The m_order.load() call refreshes the order from the database to get the recalculated GrandTotal (which includes taxes computed by MOrderLine.beforeSave()).
Key Tables Reference
The following tables are central to the requisition and procurement workflow:
| Table Name | Model Class | DocBaseType | Purpose |
|---|---|---|---|
| M_Requisition | MRequisition |
POR | Purchase requisition header: requestor, dates, priority, warehouse, price list, totals |
| M_RequisitionLine | MRequisitionLine |
— | Requisition line detail: product/charge, qty, price, vendor override, PO link |
| C_Order | MOrder |
POO | Purchase Order header: vendor, payment terms, delivery rules, warehouse |
| C_OrderLine | MOrderLine |
— | PO line detail: product, qty ordered/delivered/invoiced, pricing |
| M_Product_PO | MProductPO |
— | Product-vendor relationship: current vendor flag, vendor product number, lead time |
| M_InOut | MInOut |
MMR | Material Receipt header: vendor, movement date, warehouse |
| M_InOutLine | MInOutLine |
— | Receipt line: product, qty, locator, PO line reference |
| C_Invoice | MInvoice |
API | AP Invoice header: vendor, invoice date, payment terms |
| C_InvoiceLine | MInvoiceLine |
— | Invoice line: product, qty, pricing, PO/receipt references |
| C_Payment | MPayment |
APP | AP Payment: vendor, amount, payment method, bank account |
| M_PriceList | MPriceList |
— | Price list definition: currency, tax-inclusive flag, precision |
| C_Charge | MCharge |
— | Charge/expense type: amount, tax category, default vendor |
Document Status Values Reference
These constants from DocAction represent the document lifecycle states:
| Constant | Value | Description |
|---|---|---|
STATUS_Drafted |
"DR" | Initial state — document is being created/edited |
STATUS_InProgress |
"IP" | Document has been prepared and is ready for completion |
STATUS_Completed |
"CO" | Document is finalized — triggers downstream processing |
STATUS_Approved |
"AP" | Approved by workflow (before completion) |
STATUS_NotApproved |
"NA" | Rejected by workflow |
STATUS_Voided |
"VO" | Document has been voided/canceled |
STATUS_Closed |
"CL" | Document is closed — no further changes |
STATUS_Reversed |
"RE" | Document has been reversed (not applicable to requisitions) |
STATUS_Unknown |
"??" | Unknown/error state |
STATUS_WaitingPayment |
"WP" | Waiting for payment processing |
STATUS_WaitingConfirmation |
"WC" | Waiting for external confirmation |
Document Action Values Reference
| Constant | Value | Description |
|---|---|---|
ACTION_Complete |
"CO" | Complete the document |
ACTION_Approve |
"AP" | Approve the document |
ACTION_Reject |
"RJ" | Reject the document |
ACTION_Void |
"VO" | Void the document |
ACTION_Close |
"CL" | Close the document |
ACTION_Prepare |
"PR" | Prepare the document for completion |
ACTION_ReActivate |
"RE" | Reactivate a completed document |
ACTION_Reverse_Correct |
"RC" | Reverse with correction entry |
ACTION_Reverse_Accrual |
"RA" | Reverse with accrual entry |
ACTION_None |
"--" | No action available |
ACTION_Unlock |
"XL" | Unlock a locked document |
ACTION_Invalidate |
"IN" | Invalidate the document |
ACTION_Post |
"PO" | Post to accounting |
ACTION_WaitComplete |
"WC" | Mark as waiting for completion |
Complete Code Examples
The following examples demonstrate real-world usage of the requisition classes, built from the actual iDempiere API. These examples show the complete lifecycle from creating a requisition through to programmatic PO generation.
Example 1: Creating a Requisition with Lines
This example creates a new requisition with two product lines and one charge line:
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.util.Properties;
import org.compiere.model.MRequisition;
import org.compiere.model.MRequisitionLine;
import org.compiere.process.DocAction;
import org.compiere.util.Env;
public void createRequisition(Properties ctx, String trxName) {
// === Create the Requisition Header ===
MRequisition req = new MRequisition(ctx, 0, trxName);
req.setAD_Org_ID(1000000); // Organization
req.setAD_User_ID(1000001); // Requesting user
req.setM_Warehouse_ID(1000000); // Delivery warehouse
req.setM_PriceList_ID(1000001); // Purchase price list
req.setDescription("Office supplies for Q1");
// Override defaults: set a future required date
Timestamp nextWeek = new Timestamp(
System.currentTimeMillis() + 7L * 24 * 60 * 60 * 1000);
req.setDateRequired(nextWeek);
// Set high priority
req.setPriorityRule(MRequisition.PRIORITYRULE_High); // "3"
req.saveEx();
System.out.println("Created requisition: " + req.getDocumentNo()
+ " [ID=" + req.getM_Requisition_ID() + "]");
// === Add Line 1: Product (Printer Paper, qty 50) ===
MRequisitionLine line1 = new MRequisitionLine(req);
line1.setM_Product_ID(1000010); // Printer Paper
line1.setQty(new BigDecimal("50"));
// Price will be auto-set from price list by beforeSave()
// UOM will be auto-set from product by beforeSave()
// Line number will be auto-set to 10 by beforeSave()
line1.saveEx();
System.out.println(" Line " + line1.getLine()
+ ": Product=" + line1.getM_Product_ID()
+ ", Qty=" + line1.getQty()
+ ", Price=" + line1.getPriceActual()
+ ", LineNet=" + line1.getLineNetAmt());
// === Add Line 2: Product with vendor override ===
MRequisitionLine line2 = new MRequisitionLine(req);
line2.setM_Product_ID(1000011); // Toner Cartridge
line2.setQty(new BigDecimal("10"));
line2.setC_BPartner_ID(1000005); // Preferred vendor override
line2.saveEx();
System.out.println(" Line " + line2.getLine()
+ ": Product=" + line2.getM_Product_ID()
+ ", Qty=" + line2.getQty()
+ ", Vendor=" + line2.getC_BPartner_ID());
// === Add Line 3: Charge (Consulting Services) ===
MRequisitionLine line3 = new MRequisitionLine(req);
line3.setC_Charge_ID(1000002); // Consulting charge
line3.setQty(new BigDecimal("8")); // 8 hours
// Price auto-set from charge amount by beforeSave()
line3.setDescription("IT consulting for printer setup");
line3.saveEx();
System.out.println(" Line " + line3.getLine()
+ ": Charge=" + line3.getC_Charge_ID()
+ ", Qty=" + line3.getQty()
+ ", Price=" + line3.getPriceActual());
// === Verify header total was updated ===
req.load(trxName); // Refresh from DB (afterSave updated it)
System.out.println("Requisition total: " + req.getTotalLines());
}
Example 2: Processing a Requisition Through Approval and Completion
This example demonstrates the complete document workflow, including error handling:
import org.compiere.model.MRequisition;
import org.compiere.process.DocAction;
import org.compiere.process.DocumentEngine;
public String processRequisition(Properties ctx,
int M_Requisition_ID, String trxName) {
MRequisition req = new MRequisition(ctx, M_Requisition_ID, trxName);
System.out.println("Processing: " + req.getDocumentNo()
+ " [Status=" + req.getDocStatus() + "]");
// === Step 1: Validate current state ===
if (!DocAction.STATUS_Drafted.equals(req.getDocStatus())) {
return "Error: Requisition is not in Draft status. "
+ "Current status: " + req.getDocStatus();
}
// === Step 2: Prepare the document ===
// prepareIt() validates fields, checks period, recalculates totals
String prepStatus;
try {
prepStatus = req.prepareIt();
} catch (Exception e) {
return "Preparation failed: " + e.getMessage();
}
if (!DocAction.STATUS_InProgress.equals(prepStatus)) {
return "Preparation returned invalid status: " + prepStatus
+ ". Message: " + req.getProcessMsg();
}
System.out.println("Prepared successfully. "
+ "TotalLines=" + req.getTotalLines()
+ ", Lines=" + req.getLines().length);
// === Step 3: Complete using processIt() ===
// Using processIt() delegates to DocumentEngine, which handles
// the full state machine (prepare -> complete)
boolean success = req.processIt(DocAction.ACTION_Complete);
if (!success) {
return "Completion failed: " + req.getProcessMsg();
}
// === Step 4: Save the updated document ===
req.saveEx();
// === Verify final state ===
System.out.println("Completed: " + req.getDocumentNo()
+ " [Status=" + req.getDocStatus()
+ ", Approved=" + req.isApproved()
+ ", Processed=" + req.isProcessed()
+ ", NextAction=" + req.getDocAction() + "]");
// Expected output:
// Status=CO, Approved=true, Processed=true, NextAction=CL
return "Success: " + req.getDocumentNo() + " completed.";
}
// === Alternative: Using DocumentEngine directly ===
public void processWithEngine(Properties ctx,
int M_Requisition_ID, String trxName) {
MRequisition req = new MRequisition(ctx, M_Requisition_ID, trxName);
// DocumentEngine manages the state transitions
DocumentEngine engine = new DocumentEngine(req, req.getDocStatus());
// This single call handles Prepare + Complete
boolean success = engine.processIt(
DocAction.ACTION_Complete, DocAction.ACTION_Complete);
if (success) {
req.saveEx();
System.out.println("Document processed to: "
+ req.getDocStatus());
} else {
System.out.println("Processing failed: "
+ req.getProcessMsg());
}
}
Example 3: Programmatic Requisition-to-PO Conversion
This example shows how to manually convert requisition lines to a Purchase Order, replicating the core logic of RequisitionPOCreate for scenarios where you need more control:
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Properties;
import org.apache.commons.collections.keyvalue.MultiKey;
import org.compiere.model.*;
import org.compiere.process.DocAction;
public void convertRequisitionToPO(Properties ctx,
int M_Requisition_ID, String trxName) {
// === Load and validate the requisition ===
MRequisition req = new MRequisition(ctx, M_Requisition_ID, trxName);
if (!MRequisition.DOCSTATUS_Completed.equals(req.getDocStatus())) {
throw new IllegalStateException(
"Requisition must be Completed. Current: "
+ req.getDocStatus());
}
// === Collect unlinked lines ===
MRequisitionLine[] allLines = req.getLines();
java.util.List<MRequisitionLine> unlinkedLines =
new java.util.ArrayList<>();
for (MRequisitionLine line : allLines) {
if (line.getC_OrderLine_ID() == 0
&& (line.getM_Product_ID() != 0
|| line.getC_Charge_ID() != 0)) {
unlinkedLines.add(line);
}
}
if (unlinkedLines.isEmpty()) {
System.out.println("No unlinked lines to process.");
return;
}
// === Group lines by vendor ===
// Map: C_BPartner_ID -> List of requisition lines
HashMap<Integer, java.util.List<MRequisitionLine>> vendorLines =
new HashMap<>();
for (MRequisitionLine rLine : unlinkedLines) {
int vendorId = resolveVendor(ctx, rLine);
vendorLines.computeIfAbsent(vendorId,
k -> new java.util.ArrayList<>()).add(rLine);
}
// === Create one PO per vendor ===
int poCount = 0;
for (var entry : vendorLines.entrySet()) {
int C_BPartner_ID = entry.getKey();
java.util.List<MRequisitionLine> lines = entry.getValue();
// Create Purchase Order
MBPartner bpartner = MBPartner.get(ctx, C_BPartner_ID);
MOrder order = new MOrder(ctx, 0, trxName);
order.setAD_Org_ID(req.getAD_Org_ID());
order.setM_Warehouse_ID(req.getM_Warehouse_ID());
order.setDatePromised(req.getDateRequired());
order.setIsSOTrx(false); // Purchase transaction
order.setC_DocTypeTarget_ID(); // Default PO doc type
order.setBPartner(bpartner);
order.setM_PriceList_ID(req.getM_PriceList_ID());
order.setDescription("From Requisition: "
+ req.getDocumentNo());
order.saveEx();
// Create order lines
for (MRequisitionLine rLine : lines) {
MOrderLine oLine = new MOrderLine(order);
oLine.setDatePromised(req.getDateRequired());
oLine.setAD_Org_ID(rLine.getAD_Org_ID());
if (rLine.getM_Product_ID() > 0) {
MProduct product = MProduct.get(
ctx, rLine.getM_Product_ID());
oLine.setProduct(product);
oLine.setM_AttributeSetInstance_ID(
rLine.getM_AttributeSetInstance_ID());
} else {
oLine.setC_Charge_ID(rLine.getC_Charge_ID());
oLine.setPriceActual(rLine.getPriceActual());
}
oLine.setQty(rLine.getQty());
oLine.saveEx();
// Link requisition line to PO line
rLine.setC_OrderLine_ID(oLine.getC_OrderLine_ID());
rLine.saveEx();
System.out.println(" Linked req line " + rLine.getLine()
+ " -> PO line " + oLine.getLine());
}
poCount++;
System.out.println("Created PO: " + order.getDocumentNo()
+ " for vendor: " + bpartner.getName()
+ " with " + lines.size() + " lines"
+ " (Total: " + order.getGrandTotal() + ")");
}
System.out.println("Generated " + poCount
+ " Purchase Order(s) from requisition "
+ req.getDocumentNo());
}
/**
* Resolve vendor for a requisition line using the same
* priority logic as RequisitionPOCreate.
*/
private int resolveVendor(Properties ctx,
MRequisitionLine rLine) {
// Priority 1: Line-level vendor
if (rLine.getC_BPartner_ID() > 0)
return rLine.getC_BPartner_ID();
// Priority 2: Charge vendor
if (rLine.getC_Charge_ID() > 0) {
MCharge charge = MCharge.get(ctx, rLine.getC_Charge_ID());
if (charge.getC_BPartner_ID() > 0)
return charge.getC_BPartner_ID();
throw new RuntimeException(
"No vendor for charge: " + charge.getName());
}
// Priority 3: Product's current vendor (M_Product_PO)
MProductPO[] ppos = MProductPO.getOfProduct(
ctx, rLine.getM_Product_ID(), null);
for (MProductPO ppo : ppos) {
if (ppo.isCurrentVendor() && ppo.getC_BPartner_ID() > 0)
return ppo.getC_BPartner_ID();
}
// Fallback: first available vendor
if (ppos.length > 0 && ppos[0].getC_BPartner_ID() > 0)
return ppos[0].getC_BPartner_ID();
// No vendor found
MProduct product = MProduct.get(ctx, rLine.getM_Product_ID());
throw new org.adempiere.exceptions.NoVendorForProductException(
product.getName());
}
Example 4: Querying Requisition Lines for PO Eligibility
This example demonstrates how to find all requisition lines that are ready for PO creation, mirroring the query logic from RequisitionPOCreate:
import java.util.List;
import java.util.Properties;
import org.compiere.model.MRequisition;
import org.compiere.model.MRequisitionLine;
import org.compiere.model.Query;
public void findEligibleLines(Properties ctx, String trxName) {
// Build the same WHERE clause as RequisitionPOCreate
String whereClause =
"C_OrderLine_ID IS NULL"
+ " AND (M_Product_ID IS NOT NULL OR C_Charge_ID IS NOT NULL)"
+ " AND EXISTS ("
+ " SELECT 1 FROM M_Requisition r"
+ " WHERE M_RequisitionLine.M_Requisition_ID"
+ " = r.M_Requisition_ID"
+ " AND r.DocStatus = ?"
+ ")";
List<MRequisitionLine> lines = new Query(ctx,
MRequisitionLine.Table_Name, whereClause, trxName)
.setParameters(MRequisition.DOCSTATUS_Completed)
.setClient_ID()
.setOrderBy("(SELECT r.DateRequired FROM M_Requisition r"
+ " WHERE M_RequisitionLine.M_Requisition_ID"
+ " = r.M_Requisition_ID),"
+ " M_Product_ID, C_Charge_ID,"
+ " M_AttributeSetInstance_ID")
.list();
System.out.println("Found " + lines.size()
+ " eligible requisition lines:");
for (MRequisitionLine line : lines) {
MRequisition parent = line.getParent();
System.out.println(
" Req " + parent.getDocumentNo()
+ " Line " + line.getLine()
+ ": Product=" + line.getM_Product_ID()
+ ", Qty=" + line.getQty()
+ ", Vendor=" + line.getC_BPartner_ID()
+ ", DateReq=" + parent.getDateRequired());
}
}
Example 5: Unlinking Requisition Lines from a Voided PO
When a Purchase Order is voided, the requisition lines that were linked to it need to be unlinked so they can be re-processed. This example shows the pattern:
import org.compiere.model.MRequisitionLine;
public void handlePOVoid(Properties ctx,
int C_Order_ID, String trxName) {
// Find all requisition lines linked to this PO
MRequisitionLine[] linkedLines =
MRequisitionLine.forC_Order_ID(ctx, C_Order_ID, trxName);
System.out.println("Found " + linkedLines.length
+ " requisition lines linked to PO " + C_Order_ID);
// Unlink them all in one call
MRequisitionLine.unlinkC_Order_ID(ctx, C_Order_ID, trxName);
// Verify unlinking
for (MRequisitionLine line : linkedLines) {
line.load(trxName); // Refresh from DB
System.out.println(" Req line " + line.getLine()
+ " C_OrderLine_ID=" + line.getC_OrderLine_ID()
+ " (should be 0)");
}
System.out.println("Lines are now eligible for "
+ "re-processing by RequisitionPOCreate.");
}
Common Pitfalls and Best Practices
Working with the requisition subsystem in iDempiere has several common pitfalls. Understanding these will help you avoid bugs in customizations and integrations.
Pitfall 1: Missing M_Product_PO Records
The most common error when running RequisitionPOCreate is NoVendorForProductException. Every product that appears on a requisition line (without a line-level vendor override) must have at least one M_Product_PO record linking it to a vendor. Without this, the process cannot determine who to order from.
Best practice: Create a model validator that checks for M_Product_PO records during requisition line creation (TIMING_BEFORE_NEW on M_RequisitionLine) and warns the user immediately, rather than letting them discover the issue when running RequisitionPOCreate later.
Pitfall 2: Reactivation Is Not Supported
As discussed earlier, reActivateIt() always returns false because reverseCorrectIt() is not implemented. If your business process requires modifying completed requisitions, you have two options:
- Void and recreate: Void the existing requisition and create a new one with the corrected information.
- Custom model validator: Implement a model validator that handles
TIMING_BEFORE_REACTIVATEand performs the necessary state cleanup (unlinking PO lines, resetting Processed flag, etc.).
Pitfall 3: Price List Configuration
The MRequisition.beforeSave() method calls setM_PriceList_ID() which looks for a default purchase price list. If no default price list exists, it falls back to the default sales price list. If neither exists, the price list remains zero, and prepareIt() will return STATUS_Invalid without a clear error message. Ensure your system always has a default purchase price list configured.
Pitfall 4: Consolidation Behavior
When p_ConsolidateDocument is true, lines from multiple requisitions with the same vendor, required date, and price list are merged into a single PO. This means the PO description will not contain a specific requisition reference (the description is only set when consolidation is disabled). Audit trail visibility depends on the C_OrderLine_ID link on each requisition line.
Pitfall 5: Period Open Validation
The prepareIt() method validates that the accounting period is open for the document base type "POR" (Purchase Requisition). If your organization uses separate period control for different document types, ensure the POR period is open. This is a common source of "Cannot complete requisition" errors at month/quarter boundaries.
Best Practices Summary
| Area | Best Practice |
|---|---|
| Vendor Setup | Ensure every purchasable product has at least one M_Product_PO record with IsCurrentVendor=Y |
| Price Lists | Configure a default purchase price list; ensure price list versions cover the required date range |
| Periods | Open accounting periods for DocBaseType POR before creating requisitions |
| Line Vendors | Use line-level C_BPartner_ID for one-off vendor overrides; use M_Product_PO for systematic vendor assignments |
| Consolidation | Use ConsolidateDocument=Y for high-volume environments to minimize PO count; use N for detailed audit trails |
| Error Handling | Always check processIt() return value AND getProcessMsg() — some failures only provide messages through the latter |
| Testing | When testing RequisitionPOCreate, verify both the PO creation and the C_OrderLine_ID linkback on the requisition line |
Summary
This lesson covered the first stage of iDempiere's Procure-to-Pay lifecycle: the Purchase Requisition, and the automated process that bridges requisitions to Purchase Orders. Here are the key takeaways:
- The P2P lifecycle consists of five document stages: Requisition (POR) to Purchase Order (POO) to Material Receipt (MMR) to AP Invoice (API) to AP Payment (APP). Each stage has a dedicated model class and DocBaseType constant.
- MRequisition implements DocAction with a complete document lifecycle. New requisitions default to Draft status with Medium priority and the current date. The class does not support reversal or reactivation.
- prepareIt() validates three mandatory header fields (user, price list, warehouse), requires at least one line, validates period openness for DocBaseType POR, and recalculates all line amounts and the header total.
- completeIt() re-runs preparation if needed, sets a definite document number (if configured), implicitly approves the requisition, and sets Processed=true with DocAction=Close.
- closeIt() adjusts line quantities: unlinked lines are zeroed out, linked lines use the PO's actual ordered quantity. Original quantities are preserved in the description field in square brackets.
- MRequisitionLine manages line-level detail with automatic line numbering, mutual exclusivity between products and charges, automatic pricing from the price list or charge amount, and automatic LineNetAmt calculation.
- The C_OrderLine_ID field on MRequisitionLine is the critical link between requisitions and Purchase Orders. It is set by RequisitionPOCreate and checked by closeIt() to determine quantity adjustments. Static methods forC_Order_ID() and unlinkC_Order_ID() manage this link lifecycle.
- RequisitionPOCreate converts completed requisition lines into Purchase Orders using a three-level vendor resolution: line-level override, charge vendor, or product's current vendor from M_Product_PO.
- Order consolidation uses a HashMap with a MultiKey of (C_BPartner_ID, DateRequired, M_PriceList_ID) to group requisition lines into the minimum number of Purchase Orders. Same-product lines within a consolidated order are further aggregated by quantity.
- Error handling is asymmetric: Missing header fields silently return STATUS_Invalid, while missing lines throw an AdempiereException and missing vendors throw NoVendorForProductException. Always check both the return value and getProcessMsg() when processing documents programmatically.