Purchase Orders
Overview
- What you’ll learn: How iDempiere implements Purchase Orders through the MOrder and MOrderLine model classes, how price resolution works via MProductPricing, and how the document action workflow drives POs from draft through completion. You will study the actual Java source code that powers the procurement cycle.
- Prerequisites: Lesson 1 (Requisitions), basic understanding of the iDempiere Application Dictionary, familiarity with the DocAction interface, and comfort reading Java source code.
- Estimated reading time: 55-65 minutes
1. Introduction to Purchase Orders in iDempiere
The Purchase Order is the cornerstone document of the Procurement module. It represents a formal commitment to a vendor to buy goods or services at agreed-upon prices and terms. In iDempiere, Purchase Orders share the same underlying data model as Sales Orders — a design decision inherited from Compiere that maximizes code reuse while introducing critical branching logic controlled by a single boolean flag.
1.1 The C_Order Table — One Table, Two Worlds
Both Purchase Orders and Sales Orders live in the C_Order table. The column that separates these two worlds is IsSOTrx:
| Column | Value for Purchase Order | Value for Sales Order |
|---|---|---|
IsSOTrx |
'N' (false) |
'Y' (true) |
C_DocType_ID DocBaseType |
POO (PurchaseOrder) |
SOO (SalesOrder) |
| Price List Source | PO_PriceList_ID from BPartner |
M_PriceList_ID from BPartner |
| Payment Term Source | PO_PaymentTerm_ID from BPartner |
C_PaymentTerm_ID from BPartner |
| Payment Rule Source | PaymentRulePO from BPartner (fallback to PaymentRule) |
PaymentRule from BPartner |
| Reservation Behavior | Creates “Ordered” entries in storage | Creates “Reserved” entries in storage |
| Completion Extras | Runs landedCostAllocation() |
May auto-generate shipments/invoices |
This shared-table architecture means that much of the validation, tax calculation, and document processing logic is written once in MOrder and branches at key decision points based on the isSOTrx() flag. Understanding these branch points is essential for anyone working with Purchase Orders at the code level.
1.2 Document Base Type: POO
Every document in iDempiere is classified by its Document Base Type, stored in the C_DocType table. For Purchase Orders, the Document Base Type is POO. This three-letter code appears throughout the source code — in SQL queries that select the appropriate document type, in accounting schema lookups that determine which accounts to post to, and in validation rules that control what actions are available.
The Document Base Type drives several critical behaviors:
- Period validation: The system checks whether the accounting period is open for
POOdocuments duringprepareIt(). - Document numbering: The document sequence assigned to the
POOdocument type generates the Purchase Order number. - Accounting: The posting logic uses
POOto locate the correct GL accounts for purchase order commitments. - Matching: The matching SQL constants in
MOrderexplicitly filter onDocBaseType='POO'to find only purchase order lines for receipt and invoice matching.
1.3 Why This Design Matters for Developers
If you come from a background where purchase orders and sales orders are entirely separate entities, the shared-table model can be surprising. Here are the practical implications:
- Model Validators: A model validator on
MOrderfires for both POs and SOs. You must checkisSOTrx()if your logic should apply only to one type. - Callouts: Callouts registered on the
C_Orderwindow apply to both the Purchase Order window and the Sales Order window. TheIsSOTrxcontext variable distinguishes them. - Reports and Queries: Any SQL query against
C_Orderthat does not filter onIsSOTrxorDocBaseTypewill return both POs and SOs — a common source of bugs in custom reports. - Customizations: Adding a column to
C_Orderadds it for both document types. Use display logic (@IsSOTrx@='N') to show fields only on Purchase Orders.
2. MOrder for Purchase Orders
The MOrder class (org.compiere.model.MOrder) extends the generated X_C_Order class and implements the DocAction interface. It is the central model class for all order documents — both purchase and sales. Let us examine its structure with a focus on purchase-order-specific behavior.
Source file: org.adempiere.base/src/org/compiere/model/MOrder.java
2.1 Class Declaration and Inheritance
public class MOrder extends X_C_Order implements DocAction
{
private static final long serialVersionUID = 9095740800513665542L;
/** Order Lines */
protected MOrderLine[] m_lines = null;
/** Tax Lines */
protected MOrderTax[] m_taxes = null;
/** Force Creation of order */
protected boolean m_forceCreation = false;
// ... methods follow
}
The class maintains cached arrays of order lines and tax lines. The m_forceCreation flag is used during completion to override certain validations (relevant for prepay orders on the sales side, but understanding the field helps when reading the completeIt() method).
2.2 Constructors
MOrder provides several constructors, each suited to different creation scenarios:
// Standard constructor — loads existing order or creates new one
public MOrder(Properties ctx, int C_Order_ID, String trxName)
{
this(ctx, C_Order_ID, trxName, (String[]) null);
}
// Constructor with virtual columns support
public MOrder(Properties ctx, int C_Order_ID, String trxName, String... virtualColumns) {
super(ctx, C_Order_ID, trxName, virtualColumns);
if (C_Order_ID == 0)
setInitialDefaults();
}
// UUID-based constructor
public MOrder(Properties ctx, String C_Order_UU, String trxName) {
super(ctx, C_Order_UU, trxName);
if (Util.isEmpty(C_Order_UU))
setInitialDefaults();
}
// Project constructor — creates order from a project
public MOrder(MProject project, boolean IsSOTrx, String DocSubTypeSO)
{
this(project.getCtx(), 0, project.get_TrxName());
setAD_Client_ID(project.getAD_Client_ID());
setAD_Org_ID(project.getAD_Org_ID());
setC_Campaign_ID(project.getC_Campaign_ID());
setSalesRep_ID(project.getSalesRep_ID());
setC_Project_ID(project.getC_Project_ID());
setDescription(project.getName());
// ... date and partner setup ...
setIsSOTrx(IsSOTrx);
if (IsSOTrx)
{
if (DocSubTypeSO == null || DocSubTypeSO.length() == 0)
setC_DocTypeTarget_ID(DocSubTypeSO_OnCredit);
else
setC_DocTypeTarget_ID(DocSubTypeSO);
}
else
setC_DocTypeTarget_ID(); // auto-selects POO document type
}
// ResultSet constructor — loads from query result
public MOrder(Properties ctx, ResultSet rs, String trxName)
{
super(ctx, rs, trxName);
}
Notice that the project constructor calls the no-argument setC_DocTypeTarget_ID() when IsSOTrx is false. This triggers the PO-specific document type selection logic that we will examine next.
2.3 Initial Defaults
When a new order is created (ID of 0), the setInitialDefaults() method establishes sensible starting values:
private void setInitialDefaults() {
setDocStatus(DOCSTATUS_Drafted);
setDocAction(DOCACTION_Prepare);
//
setDeliveryRule(DELIVERYRULE_Availability);
setFreightCostRule(FREIGHTCOSTRULE_FreightIncluded);
setInvoiceRule(INVOICERULE_Immediate);
setPaymentRule(PAYMENTRULE_OnCredit);
setPriorityRule(PRIORITYRULE_Medium);
setDeliveryViaRule(DELIVERYVIARULE_Pickup);
//
setIsDiscountPrinted(false);
setIsSelected(false);
setIsTaxIncluded(false);
setIsSOTrx(true); // DEFAULT is Sales Order!
setIsDropShip(false);
setSendEMail(false);
//
setIsApproved(false);
setIsPrinted(false);
setIsCreditApproved(false);
setIsDelivered(false);
setIsInvoiced(false);
setIsTransferred(false);
setIsSelfService(false);
//
super.setProcessed(false);
setProcessing(false);
setPosted(false);
setDateAcct(new Timestamp(System.currentTimeMillis()));
setDatePromised(new Timestamp(System.currentTimeMillis()));
setDateOrdered(new Timestamp(System.currentTimeMillis()));
setFreightAmt(Env.ZERO);
setChargeAmt(Env.ZERO);
setTotalLines(Env.ZERO);
setGrandTotal(Env.ZERO);
}
Critical observation: The default value for IsSOTrx is true (Sales Order). When creating a Purchase Order programmatically, you must explicitly call setIsSOTrx(false) before setting the business partner or document type. If you forget this step, the system will resolve sales-side price lists and payment terms instead of purchase-side ones.
2.4 Document Type Auto-Selection
The no-argument setC_DocTypeTarget_ID() method selects the default document type based on the IsSOTrx flag:
/**
* Set Target Document Type.
* Standard Order or PO.
*/
public void setC_DocTypeTarget_ID()
{
if (isSOTrx()) // SO = Std Order
{
setC_DocTypeTarget_ID(DocSubTypeSO_Standard);
return;
}
// PO
String sql = "SELECT C_DocType_ID FROM C_DocType "
+ "WHERE AD_Client_ID=? AND AD_Org_ID IN (0," + getAD_Org_ID()
+ ") AND DocBaseType='POO' "
+ "ORDER BY AD_Org_ID DESC, IsDefault DESC";
int C_DocType_ID = DB.getSQLValue(null, sql, getAD_Client_ID());
if (C_DocType_ID <= 0)
log.severe("No POO found for AD_Client_ID=" + getAD_Client_ID());
else
{
if (log.isLoggable(Level.FINE)) log.fine("(PO) - " + C_DocType_ID);
setC_DocTypeTarget_ID(C_DocType_ID);
}
}
Key points about the PO document type selection:
- It queries
C_DocTypefor rows whereDocBaseType='POO'. - It filters by the current client (
AD_Client_ID) and accepts organization-level or system-level (Org 0) document types. - The
ORDER BY AD_Org_ID DESC, IsDefault DESCclause ensures that organization-specific types take priority over system types, and within those, the default type is preferred. - Unlike the sales order version, there is no
DocSubTypeSOfiltering — Purchase Orders do not have sub-types.
2.5 DocSubType Constants — Sales Order Only
For educational contrast, MOrder defines several DocSubTypeSO constants. These apply exclusively to Sales Orders but appear throughout completeIt(), so understanding them helps you identify which code paths are PO-relevant and which are SO-only:
/** Sales Order Sub Type - SO */
public static final String DocSubTypeSO_Standard = "SO";
/** Sales Order Sub Type - OB (Binding Offer) */
public static final String DocSubTypeSO_Quotation = "OB";
/** Sales Order Sub Type - ON (Non-Binding Offer) */
public static final String DocSubTypeSO_Proposal = "ON";
/** Sales Order Sub Type - PR (Prepay) */
public static final String DocSubTypeSO_Prepay = "PR";
/** Sales Order Sub Type - WR (Walk-in Receipt / POS) */
public static final String DocSubTypeSO_POS = "WR";
/** Sales Order Sub Type - WP (Warehouse) */
public static final String DocSubTypeSO_Warehouse = "WP";
/** Sales Order Sub Type - WI (Will-call Invoice / On Credit) */
public static final String DocSubTypeSO_OnCredit = "WI";
/** Sales Order Sub Type - RM (Return Material) */
public static final String DocSubTypeSO_RMA = "RM";
Purchase Orders have no equivalent sub-types. A PO is a PO — the distinction comes from the DocBaseType being POO rather than from sub-type variations.
2.6 setBPartner() — PO-Specific Behavior
The setBPartner(MBPartner bp) method is one of the most important branching points in MOrder. When called on a Purchase Order, it resolves vendor-specific defaults from the business partner:
public void setBPartner(MBPartner bp)
{
if (bp == null)
return;
setC_BPartner_ID(bp.getC_BPartner_ID());
// Defaults Payment Term
int ii = 0;
if (isSOTrx())
ii = bp.getC_PaymentTerm_ID(); // Sales payment term
else
ii = bp.getPO_PaymentTerm_ID(); // Purchase payment term
if (ii != 0)
setC_PaymentTerm_ID(ii);
// Default Price List
if (isSOTrx())
ii = bp.getM_PriceList_ID(); // Sales price list
else
ii = bp.getPO_PriceList_ID(); // Purchase price list
if (ii != 0)
setM_PriceList_ID(ii);
// Default Delivery/Via Rule
String ss = bp.getDeliveryRule();
if (ss != null)
setDeliveryRule(ss);
ss = bp.getDeliveryViaRule();
if (ss != null)
setDeliveryViaRule(ss);
// Default Invoice/Payment Rule
ss = bp.getInvoiceRule();
if (ss != null)
setInvoiceRule(ss);
if (isSOTrx())
ss = bp.getPaymentRule();
else
ss = !Util.isEmpty(bp.getPaymentRulePO())
? bp.getPaymentRulePO()
: bp.getPaymentRule(); // PO payment rule with fallback
if (ss != null)
setPaymentRule(ss);
// Sales Rep
ii = bp.getSalesRep_ID();
if (ii != 0)
setSalesRep_ID(ii);
// Set Locations
MBPartnerLocation[] locs = bp.getLocations(false);
if (locs != null)
{
for (int i = 0; i < locs.length; i++)
{
if (locs[i].isShipTo())
super.setC_BPartner_Location_ID(locs[i].getC_BPartner_Location_ID());
if (locs[i].isBillTo())
setBill_Location_ID(locs[i].getC_BPartner_Location_ID());
}
// set to first if none found
if (getC_BPartner_Location_ID() == 0 && locs.length > 0)
super.setC_BPartner_Location_ID(locs[0].getC_BPartner_Location_ID());
if (getBill_Location_ID() == 0 && locs.length > 0)
setBill_Location_ID(locs[0].getC_BPartner_Location_ID());
}
if (getC_BPartner_Location_ID() == 0)
throw new BPartnerNoShipToAddressException(bp);
if (getBill_Location_ID() == 0)
throw new BPartnerNoBillToAddressException(bp);
// Set Contact
MUser[] contacts = bp.getContacts(false);
if (contacts != null && contacts.length == 1)
setAD_User_ID(contacts[0].getAD_User_ID());
}
The following table summarizes the PO vs SO field resolution within setBPartner():
| Field Being Set | PO Source (isSOTrx=false) | SO Source (isSOTrx=true) |
|---|---|---|
| Payment Term | bp.getPO_PaymentTerm_ID() |
bp.getC_PaymentTerm_ID() |
| Price List | bp.getPO_PriceList_ID() |
bp.getM_PriceList_ID() |
| Payment Rule | bp.getPaymentRulePO() (fallback: bp.getPaymentRule()) |
bp.getPaymentRule() |
This is why calling setIsSOTrx(false) before setBPartner() is mandatory — the method inspects the flag to decide which vendor-specific fields to read.
2.7 Matching SQL Constants
MOrder defines several static SQL string constants that power the PO Matching functionality. These are used to find Purchase Order lines that need to be matched against Material Receipts or Vendor Invoices:
/** Matching SELECT SQL template */
private static final String BASE_MATCHING_SQL =
"""
SELECT hdr.C_Order_ID, hdr.DocumentNo, hdr.DateOrdered,
bp.Name, hdr.C_BPartner_ID,
lin.Line, lin.C_OrderLine_ID, p.Name, lin.M_Product_ID,
lin.QtyOrdered,
%s,
org.Name, hdr.AD_Org_ID
FROM C_Order hdr
INNER JOIN AD_Org org ON (hdr.AD_Org_ID=org.AD_Org_ID)
INNER JOIN C_BPartner bp ON (hdr.C_BPartner_ID=bp.C_BPartner_ID)
INNER JOIN C_OrderLine lin ON (hdr.C_Order_ID=lin.C_Order_ID)
INNER JOIN M_Product p ON (lin.M_Product_ID=p.M_Product_ID)
INNER JOIN C_DocType dt ON (hdr.C_DocType_ID=dt.C_DocType_ID
AND dt.DocBaseType='POO')
FULL JOIN M_MatchPO mo ON (lin.C_OrderLine_ID=mo.C_OrderLine_ID)
WHERE %s
AND hdr.DocStatus IN ('CO','CL')
""";
The template uses Java text blocks and String.formatted() to produce four concrete query variants:
| Constant Name | Purpose |
|---|---|
NOT_FULLY_MATCHED_TO_RECEIPT |
Find PO lines where QtyOrdered exceeds the sum of M_MatchPO.Qty for receipt matches |
NOT_FULLY_MATCHED_TO_INVOICE |
Find PO lines where QtyOrdered exceeds the sum of M_MatchPO.Qty for invoice matches |
FULL_OR_PARTIALLY_MATCHED_TO_RECEIPT |
Find PO lines that have at least one receipt match |
FULL_OR_PARTIALLY_MATCHED_TO_INVOICE |
Find PO lines that have at least one invoice match |
Each constant has an accompanying GROUP_BY constant with a HAVING clause to filter the aggregated results. The query results are mapped to the MatchingRecord record class:
public static record MatchingRecord(
int C_Order_ID,
String documentNo,
Timestamp documentDate,
String businessPartnerName,
int C_BPartner_ID,
int line,
int C_OrderLine_ID,
String productName,
int M_Product_ID,
BigDecimal qtyOrdered,
BigDecimal matchedQty,
String organizationName,
int AD_Org_ID
) {}
The static methods that execute these queries accept filtering parameters:
public static List<MatchingRecord> getNotFullyMatchedToReceipt(
int C_BPartner_ID, int M_Product_ID, int M_InOutLine_ID,
Timestamp from, Timestamp to, String trxName)
public static List<MatchingRecord> getFullOrPartiallyMatchedToReceipt(
int C_BPartner_ID, int M_Product_ID, int M_InOutLine_ID,
Timestamp from, Timestamp to, String trxName)
public static List<MatchingRecord> getNotFullyMatchedToInvoice(
int C_BPartner_ID, int M_Product_ID, int C_InvoiceLine_ID,
Timestamp from, Timestamp to, String trxName)
public static List<MatchingRecord> getFullOrPartiallyMatchedToInvoice(
int C_BPartner_ID, int M_Product_ID, int C_InvoiceLine_ID,
Timestamp from, Timestamp to, String trxName)
2.8 Key Methods on MOrder
The following methods form the core API surface of MOrder that developers interact with most frequently:
Line and Tax Management
// Get order lines with optional filtering and sorting
public MOrderLine[] getLines(String whereClause, String orderClause)
public MOrderLine[] getLines(boolean requery, String orderBy)
public MOrderLine[] getLines() // convenience, returns cached lines
// Renumber lines with given step (e.g., step=10 gives 10, 20, 30...)
public void renumberLines(int step)
// Get tax lines
public MOrderTax[] getTaxes(boolean requery)
// Calculate tax total across all lines
public boolean calculateTaxTotal()
// Validate payment schedule against order total
public boolean validatePaySchedule()
Related Document Access
// Get the invoice generated from this order (if any)
public int getC_Invoice_ID()
Order Copying
// Static method to duplicate an order
public static MOrder copyFrom(
MOrder from,
Timestamp dateDoc,
int C_DocTypeTarget_ID,
boolean isSOTrx,
boolean counter,
boolean copyASI,
String trxName)
// Copy lines from another order
public int copyLinesFrom(MOrder otherOrder, boolean counter, boolean copyASI)
The copyFrom() static method is particularly useful for creating new POs based on existing ones. It handles all the bookkeeping: resetting document status to Drafted, clearing quantities, resetting flags, and copying lines. Here is the implementation:
public static MOrder copyFrom(MOrder from, Timestamp dateDoc,
int C_DocTypeTarget_ID, boolean isSOTrx, boolean counter,
boolean copyASI, String trxName)
{
MOrder to = new MOrder(from.getCtx(), 0, trxName);
to.set_TrxName(trxName);
PO.copyValues(from, to, from.getAD_Client_ID(), from.getAD_Org_ID());
to.set_ValueNoCheck("C_Order_ID", I_ZERO);
to.set_ValueNoCheck("DocumentNo", null);
//
to.setDocStatus(DOCSTATUS_Drafted);
to.setDocAction(DOCACTION_Complete);
//
to.setC_DocType_ID(0);
to.setC_DocTypeTarget_ID(C_DocTypeTarget_ID);
to.setIsSOTrx(isSOTrx);
//
to.setIsSelected(false);
to.setDateOrdered(dateDoc);
to.setDateAcct(dateDoc);
to.setDatePromised(dateDoc);
to.setDatePrinted(null);
to.setIsPrinted(false);
//
to.setIsApproved(false);
to.setIsCreditApproved(false);
to.setC_Payment_ID(0);
to.setC_CashLine_ID(0);
// Amounts are updated when adding lines
to.setGrandTotal(Env.ZERO);
to.setTotalLines(Env.ZERO);
//
to.setIsDelivered(false);
to.setIsInvoiced(false);
to.setIsSelfService(false);
to.setIsTransferred(false);
to.setPosted(false);
to.setProcessed(false);
// ... counter document handling ...
if (!to.save(trxName))
throw new IllegalStateException("Could not create Order");
if (to.copyLinesFrom(from, counter, copyASI) == 0)
throw new IllegalStateException("Could not create Order Lines");
// don't copy linked PO/SO
to.setLink_Order_ID(0);
return to;
}
The copyLinesFrom() method iterates through the source order’s lines, creates new MOrderLine instances, copies field values, and critically resets all quantity tracking fields:
public int copyLinesFrom(MOrder otherOrder, boolean counter, boolean copyASI)
{
if (isProcessed() || isPosted() || otherOrder == null)
return 0;
MOrderLine[] fromLines = otherOrder.getLines(false, null);
int count = 0;
for (int i = 0; i < fromLines.length; i++)
{
MOrderLine line = new MOrderLine(this);
PO.copyValues(fromLines[i], line, getAD_Client_ID(), getAD_Org_ID());
line.setC_Order_ID(getC_Order_ID());
//
line.setQtyDelivered(Env.ZERO);
line.setQtyInvoiced(Env.ZERO);
line.setQtyReserved(Env.ZERO);
line.setQtyLostSales(Env.ZERO);
line.setQtyEntered(fromLines[i].getQtyEntered());
// Convert quantity to ordered UOM
BigDecimal ordered = MUOMConversion.convertProductFrom(
getCtx(), line.getM_Product_ID(),
line.getC_UOM_ID(), line.getQtyEntered());
line.setQtyOrdered(ordered);
line.setDateDelivered(null);
line.setDateInvoiced(null);
line.setOrder(this);
line.set_ValueNoCheck("C_OrderLine_ID", I_ZERO); // new line
// ... counter and ASI handling ...
line.setLink_OrderLine_ID(0);
line.saveEx(get_TrxName());
count++;
}
// ... tax recalculation ...
return count;
}
3. MOrderLine — Purchase Order Lines
The MOrderLine class (org.compiere.model.MOrderLine) represents individual line items on an order. Each line specifies a product (or charge), quantity, price, and tax. For Purchase Orders, lines track how much has been received (QtyDelivered) and how much has been invoiced (QtyInvoiced).
Source file: org.adempiere.base/src/org/compiere/model/MOrderLine.java
3.1 Constructors
// Standard constructor
public MOrderLine(Properties ctx, int C_OrderLine_ID, String trxName)
// With virtual columns
public MOrderLine(Properties ctx, int C_OrderLine_ID, String trxName,
String... virtualColumns)
// UUID-based constructor
public MOrderLine(Properties ctx, String C_OrderLine_UU, String trxName)
// Parent constructor — the most common way to create new lines
public MOrderLine(MOrder order)
{
this(order.getCtx(), 0, order.get_TrxName());
if (order.get_ID() == 0)
throw new IllegalArgumentException("Header not saved");
setC_Order_ID(order.getC_Order_ID());
setOrder(order);
}
// ResultSet constructor
public MOrderLine(Properties ctx, ResultSet rs, String trxName)
The parent constructor (MOrderLine(MOrder order)) is the recommended way to create new order lines. It requires that the parent order has already been saved (has a valid ID), and it automatically links the line to its parent and copies header information.
3.2 Initial Defaults
private void setInitialDefaults() {
setFreightAmt(Env.ZERO);
setLineNetAmt(Env.ZERO);
//
setPriceEntered(Env.ZERO);
setPriceActual(Env.ZERO);
setPriceLimit(Env.ZERO);
setPriceList(Env.ZERO);
//
setM_AttributeSetInstance_ID(0);
//
setQtyEntered(Env.ZERO);
setQtyOrdered(Env.ZERO);
setQtyDelivered(Env.ZERO);
setQtyInvoiced(Env.ZERO);
setQtyReserved(Env.ZERO);
//
setIsDescription(false);
setProcessed(false);
setLine(0);
}
3.3 Quantity Fields
Understanding the quantity fields is critical for procurement. Each field serves a distinct purpose:
| Field | Type | Description |
|---|---|---|
QtyEntered |
BigDecimal | Quantity in the entered UOM (may differ from product UOM). This is what the user types. |
QtyOrdered |
BigDecimal | Quantity in the product’s base UOM. Derived from QtyEntered via UOM conversion. |
QtyDelivered |
BigDecimal | Quantity received via Material Receipts (M_InOut). Updated when receipts are completed. |
QtyInvoiced |
BigDecimal | Quantity matched to Vendor Invoices (C_Invoice). Updated during invoice matching. |
QtyReserved |
BigDecimal | For Sales Orders, the reserved quantity in inventory. For Purchase Orders, the “ordered” quantity tracked in M_StorageReservation. |
QtyLostSales |
BigDecimal | Quantity lost when an order is closed before full delivery. Set by closeIt() as QtyOrdered - QtyDelivered. |
The quantity setter methods enforce UOM precision:
/**
* Set Qty Entered/Ordered.
* Use this Method if the Line UOM is the Product UOM.
*/
public void setQty(BigDecimal Qty)
{
super.setQtyEntered(Qty);
super.setQtyOrdered(getQtyEntered());
}
/**
* Set Qty Entered - enforce entered UOM precision.
*/
public void setQtyEntered(BigDecimal QtyEntered)
{
if (QtyEntered != null && getC_UOM_ID() != 0)
{
int precision = MUOM.getPrecision(getCtx(), getC_UOM_ID());
QtyEntered = QtyEntered.setScale(precision, RoundingMode.HALF_UP);
}
super.setQtyEntered(QtyEntered);
}
/**
* Set Qty Ordered - enforce Product UOM precision.
*/
public void setQtyOrdered(BigDecimal QtyOrdered)
{
MProduct product = getProduct();
if (QtyOrdered != null && product != null)
{
int precision = product.getUOMPrecision();
QtyOrdered = QtyOrdered.setScale(precision, RoundingMode.HALF_UP);
}
super.setQtyOrdered(QtyOrdered);
}
Notice the distinction: setQtyEntered() uses the line’s UOM precision, while setQtyOrdered() uses the product’s UOM precision. The convenience method setQty() sets both to the same value, which is only correct when the line UOM matches the product UOM.
3.4 The setOrder() and setHeaderInfo() Methods
When a line is associated with an order, it inherits key header-level settings:
public void setOrder(MOrder order)
{
setClientOrg(order);
setC_BPartner_ID(order.getC_BPartner_ID());
setC_BPartner_Location_ID(order.getC_BPartner_Location_ID());
setM_Warehouse_ID(order.getM_Warehouse_ID());
setDateOrdered(order.getDateOrdered());
setDatePromised(order.getDatePromised());
setC_Currency_ID(order.getC_Currency_ID());
//
setHeaderInfo(order); // sets m_order
}
public void setHeaderInfo(MOrder order)
{
m_parent = order;
m_precision = Integer.valueOf(order.getPrecision());
m_M_PriceList_ID = order.getM_PriceList_ID();
m_IsSOTrx = order.isSOTrx();
}
The setHeaderInfo() method caches the price list ID, currency precision, and the IsSOTrx flag locally on the line. This avoids repeated queries to the parent order during price calculations and tax lookups.
3.5 Price Setting Methods
MOrderLine provides a cascade of price-setting methods that work together with the pricing engine:
/**
* Set Price Entered/Actual.
* Use this Method if the Line UOM is the Product UOM.
*/
public void setPrice(BigDecimal PriceActual)
{
setPriceEntered(PriceActual);
setPriceActual(PriceActual);
}
/**
* Set Price for Product and PriceList.
*/
public void setPrice()
{
if (getM_Product_ID() == 0)
return;
if (m_M_PriceList_ID == 0)
throw new IllegalStateException("PriceList unknown!");
setPrice(m_M_PriceList_ID);
}
/**
* Set Price for Product and PriceList
*/
public void setPrice(int M_PriceList_ID)
{
if (getM_Product_ID() == 0)
return;
getProductPricing(M_PriceList_ID);
setPriceActual(m_productPrice.getPriceStd());
setPriceList(m_productPrice.getPriceList());
setPriceLimit(m_productPrice.getPriceLimit());
//
if (getQtyEntered().compareTo(getQtyOrdered()) == 0)
setPriceEntered(getPriceActual());
else
setPriceEntered(getPriceActual().multiply(getQtyOrdered()
.divide(getQtyEntered(), 12, RoundingMode.HALF_UP)));
// Calculate Discount
setDiscount(m_productPrice.getDiscount());
// Set UOM
if (getC_UOM_ID() == 0)
setC_UOM_ID(m_productPrice.getC_UOM_ID());
}
The flow is: setPrice() (no args) calls setPrice(M_PriceList_ID), which calls getProductPricing() to invoke the pricing engine, then distributes the resolved prices to the line’s fields. The PriceEntered is adjusted for UOM conversion when QtyEntered differs from QtyOrdered.
3.6 Product Pricing Integration
protected IProductPricing getProductPricing(int M_PriceList_ID)
{
m_productPrice = Core.getProductPricing();
m_productPrice.setOrderLine(this, get_TrxName());
m_productPrice.setM_PriceList_ID(M_PriceList_ID);
//
m_productPrice.calculatePrice();
return m_productPrice;
}
The pricing engine is obtained through the Core.getProductPricing() factory, which returns an IProductPricing implementation (by default, MProductPricing). This plugin-based architecture allows customization of price resolution without modifying core code.
3.7 Tax and Discount Calculation
/**
* Set Tax
*/
public boolean setTax()
{
int ii = Core.getTaxLookup().get(getCtx(),
getM_Product_ID(), getC_Charge_ID(),
getDateOrdered(), getDateOrdered(),
getAD_Org_ID(), getM_Warehouse_ID(),
getC_BPartner_Location_ID(), // should be bill to
getC_BPartner_Location_ID(),
getParent().getDropShip_Location_ID(),
m_IsSOTrx,
getParent().getDeliveryViaRule(),
get_TrxName());
if (ii == 0)
{
log.log(Level.SEVERE, "No Tax found");
return false;
}
setC_Tax_ID(ii);
return true;
}
/**
* Calculate Extended Amt. May or may not include tax.
*/
public void setLineNetAmt()
{
BigDecimal bd = getPriceEntered().multiply(getQtyEntered());
int precision = getPrecision();
if (bd.scale() > precision)
bd = bd.setScale(precision, RoundingMode.HALF_UP);
super.setLineNetAmt(bd);
}
/**
* Calculate discount percentage (actual vs list)
*/
public void setDiscount()
{
BigDecimal list = getPriceList();
if (Env.ZERO.compareTo(list) == 0)
return;
BigDecimal discount = list.subtract(getPriceActual())
.multiply(Env.ONEHUNDRED)
.divide(list, getPrecision(), RoundingMode.HALF_UP);
setDiscount(discount);
}
The discount formula is: Discount% = (PriceList - PriceActual) / PriceList * 100. A negative discount means the actual price exceeds the list price (a surcharge).
3.8 Tax Update Methods
/**
* Recalculate order tax
* @param oldTax true if the old C_Tax_ID should be used
*/
public boolean updateOrderTax(boolean oldTax) {
int C_Tax_ID = getC_Tax_ID();
boolean isOldTax = oldTax && is_ValueChanged(MOrderLine.COLUMNNAME_C_Tax_ID);
if (isOldTax)
{
Object old = get_ValueOld(MOrderLine.COLUMNNAME_C_Tax_ID);
if (old == null)
return true;
C_Tax_ID = ((Integer)old).intValue();
}
if (C_Tax_ID == 0)
return true;
MTax t = MTax.get(C_Tax_ID);
if (t.isSummary())
{
// Handle summary (parent) taxes with child tax lines
MOrderTax[] taxes = MOrderTax.getChildTaxes(
this, getPrecision(), isOldTax, get_TrxName());
// ... calculate and save each child tax ...
}
else
{
// Handle single tax
MOrderTax tax = MOrderTax.get(this, getPrecision(), oldTax, get_TrxName());
if (tax != null) {
if (!tax.calculateTaxFromLines())
return false;
// Save or delete based on tax amount
}
}
return true;
}
/**
* Update Tax and Header
*/
public boolean updateHeaderTax()
{
if (isProcessed() && !is_ValueChanged(COLUMNNAME_Processed))
return true;
MTax tax = new MTax(getCtx(), getC_Tax_ID(), get_TrxName());
MTaxProvider provider = new MTaxProvider(
tax.getCtx(), tax.getC_TaxProvider_ID(), tax.get_TrxName());
ITaxProvider calculator = Core.getTaxProvider(provider);
if (calculator == null)
throw new AdempiereException(Msg.getMsg(getCtx(), "TaxNoProvider"));
if (!calculator.updateOrderTax(provider, this))
return false;
return calculator.updateHeaderTax(provider, this);
}
3.9 Warehouse Change Control
public boolean canChangeWarehouse()
{
if (getQtyDelivered().signum() != 0)
{
log.saveError("Error", Msg.translate(getCtx(), "QtyDelivered")
+ "=" + getQtyDelivered());
return false;
}
if (getQtyInvoiced().signum() != 0)
{
log.saveError("Error", Msg.translate(getCtx(), "QtyInvoiced")
+ "=" + getQtyInvoiced());
return false;
}
if (getQtyReserved().signum() != 0)
{
log.saveError("Error", Msg.translate(getCtx(), "QtyReserved")
+ "=" + getQtyReserved());
return false;
}
return true;
}
The warehouse cannot be changed once any quantity has been delivered, invoiced, or reserved. This prevents inventory inconsistencies that would arise from moving an in-progress order to a different warehouse.
3.10 Product Setting
public void setProduct(MProduct product)
{
m_product = product;
if (m_product != null)
{
setM_Product_ID(m_product.getM_Product_ID());
setC_UOM_ID(m_product.getC_UOM_ID());
}
else
{
setM_Product_ID(0);
set_ValueNoCheck("C_UOM_ID", null);
}
setM_AttributeSetInstance_ID(0);
}
public void setM_Product_ID(int M_Product_ID, boolean setUOM)
{
if (setUOM)
setProduct(MProduct.get(getCtx(), M_Product_ID));
else
super.setM_Product_ID(M_Product_ID);
setM_AttributeSetInstance_ID(0);
}
Setting a product always resets the Attribute Set Instance to zero. The setProduct() method also sets the UOM from the product’s default UOM, while setM_Product_ID(id, false) allows setting the product without changing the UOM.
3.11 isTaxIncluded()
public boolean isTaxIncluded()
{
if (m_M_PriceList_ID == 0)
{
m_M_PriceList_ID = DB.getSQLValue(get_TrxName(),
"SELECT M_PriceList_ID FROM C_Order WHERE C_Order_ID=?",
getC_Order_ID());
}
MPriceList pl = MPriceList.get(getCtx(), m_M_PriceList_ID, get_TrxName());
return pl.isTaxIncluded();
}
Tax inclusion is determined by the price list, not by the order or line. This method lazily loads the price list ID from the parent order if it has not been cached yet.
3.12 Static Utility: getNotReserved()
public static BigDecimal getNotReserved(Properties ctx,
int M_Warehouse_ID, int M_Product_ID,
int M_AttributeSetInstance_ID, int excludeC_OrderLine_ID)
{
BigDecimal retValue = Env.ZERO;
String sql = "SELECT SUM(QtyOrdered-QtyDelivered-QtyReserved) "
+ "FROM C_OrderLine ol"
+ " INNER JOIN C_Order o ON (ol.C_Order_ID=o.C_Order_ID) "
+ "WHERE ol.M_Warehouse_ID=?"
+ " AND M_Product_ID=?"
+ " AND o.IsSOTrx='Y' AND o.DocStatus='DR'"
+ " AND QtyOrdered-QtyDelivered-QtyReserved<>0"
+ " AND ol.C_OrderLine_ID<>?";
if (M_AttributeSetInstance_ID != 0)
sql += " AND M_AttributeSetInstance_ID=?";
// ... execute and return ...
return retValue;
}
Note that this method filters on IsSOTrx='Y' — it only checks Sales Order lines. It calculates the quantity that has been ordered but not yet reserved in inventory, which is useful for availability checking during order entry.
4. MProductPricing — Price Resolution for Purchase Orders
The MProductPricing class (org.compiere.model.MProductPricing) extends AbstractProductPricing and implements the IProductPricing interface. It is the default pricing engine used to resolve product prices from price lists. For Purchase Orders, it resolves prices from the vendor (purchase) price list hierarchy.
Source file: org.adempiere.base/src/org/compiere/model/MProductPricing.java
4.1 Constructor and Initialization
/**
* Constructor
* @param M_Product_ID product
* @param C_BPartner_ID partner
* @param Qty quantity
* @param isSOTrx SO or PO
* @param trxName the transaction
*/
public MProductPricing(int M_Product_ID, int C_BPartner_ID,
BigDecimal Qty, boolean isSOTrx, String trxName)
{
setInitialValues(M_Product_ID, C_BPartner_ID, Qty, isSOTrx, trxName);
}
@Override
public void setInitialValues(int M_Product_ID, int C_BPartner_ID,
BigDecimal qty, boolean isSOTrx, String trxName) {
super.setInitialValues(M_Product_ID, C_BPartner_ID, qty, isSOTrx, trxName);
checkVendorBreak();
}
The isSOTrx parameter controls which price list the engine searches. When false (Purchase Order), the engine will look up the vendor’s purchase price list. The checkVendorBreak() call immediately checks whether vendor break pricing records exist for this product and business partner.
4.2 Vendor Break Pricing
private void checkVendorBreak() {
int thereAreVendorBreakRecords = DB.getSQLValue(trxName,
"SELECT COUNT(M_Product_ID) FROM M_ProductPriceVendorBreak "
+ "WHERE IsActive='Y' AND M_Product_ID=? "
+ "AND (C_BPartner_ID=? OR C_BPartner_ID IS NULL)",
m_M_Product_ID, m_C_BPartner_ID);
m_useVendorBreak = thereAreVendorBreakRecords > 0;
}
Vendor break pricing (M_ProductPriceVendorBreak) allows different prices based on quantity thresholds. For example, a vendor might offer $10/unit for orders of 1-99 units, $8/unit for 100-499, and $6/unit for 500+. The m_useVendorBreak flag determines whether the pricing engine should check these quantity-based prices before falling back to standard pricing.
4.3 The calculatePrice() Method
This is the heart of the pricing engine. It tries multiple resolution strategies in order of specificity:
public boolean calculatePrice()
{
if (m_M_Product_ID == 0
|| (m_found != null && !m_found.booleanValue()))
return false;
// Phase 1: Try vendor break pricing (if applicable)
if (m_useVendorBreak) {
// Price List Version known - vendor break
if (!m_calculated) {
m_calculated = calculatePLV_VB();
if (m_calculated) m_vendorbreak = true;
}
// Price List known - vendor break
if (!m_calculated) {
m_calculated = calculatePL_VB();
if (m_calculated) m_vendorbreak = true;
}
// Base Price List used - vendor break
if (!m_calculated) {
m_calculated = calculateBPL_VB();
if (m_calculated) m_vendorbreak = true;
}
}
// Phase 2: Standard pricing
// Price List Version known
if (!m_calculated)
m_calculated = calculatePLV();
// Price List known
if (!m_calculated)
m_calculated = calculatePL();
// Base Price List used
if (!m_calculated)
m_calculated = calculateBPL();
// Set UOM, Product Category
if (!m_calculated)
setBaseInfo();
// Phase 3: Apply discount schema (if not vendor break)
if (m_calculated && !m_vendorbreak)
calculateDiscount();
setPrecision(); // from Price List
m_found = Boolean.valueOf(m_calculated);
return m_calculated;
}
The resolution strategy follows a cascading pattern:
| Priority | Method | Requires | Description |
|---|---|---|---|
| 1 | calculatePLV_VB() |
Price List Version ID + Vendor Break records | Vendor break price from a specific price list version |
| 2 | calculatePL_VB() |
Price List ID + Vendor Break records | Vendor break price from the latest valid version of a price list |
| 3 | calculateBPL_VB() |
Vendor Break records | Vendor break price using the base price list |
| 4 | calculatePLV() |
Price List Version ID | Standard price from a specific price list version |
| 5 | calculatePL() |
Price List ID | Standard price from the latest valid version of a price list |
| 6 | calculateBPL() |
(none) | Standard price using the base price list |
4.4 The Price List Hierarchy
Understanding the price list data model is essential for procurement:
M_PriceList -- Top level: currency, tax included flag
└── M_PriceList_Version -- Versioned by ValidFrom date
└── M_ProductPrice -- Per-product prices
├── PriceList -- "Catalog" or "Suggested Retail" price
├── PriceStd -- Standard/actual selling/buying price
└── PriceLimit -- Minimum (SO) or Maximum (PO) price
For Purchase Orders:
- PriceList: The vendor’s published catalog price. Used as the basis for discount calculations.
- PriceStd: The negotiated purchase price. This becomes
PriceActualon the order line. - PriceLimit: The maximum price you are willing to pay. If
EnforcePriceLimitis enabled on the price list, the system prevents purchasing above this price.
The SQL used by calculatePLV() to resolve prices demonstrates the BOM-aware pricing functions:
String sql = "SELECT bomPriceStd(p.M_Product_ID, pv.M_PriceList_Version_ID) AS PriceStd,"
+ " bomPriceList(p.M_Product_ID, pv.M_PriceList_Version_ID) AS PriceList,"
+ " bomPriceLimit(p.M_Product_ID, pv.M_PriceList_Version_ID) AS PriceLimit,"
+ " p.C_UOM_ID, pv.ValidFrom, pl.C_Currency_ID, p.M_Product_Category_ID,"
+ " pl.EnforcePriceLimit, pl.IsTaxIncluded "
+ "FROM M_Product p"
+ " INNER JOIN M_ProductPrice pp ON (p.M_Product_ID=pp.M_Product_ID)"
+ " INNER JOIN M_PriceList_Version pv ON (pp.M_PriceList_Version_ID=pv.M_PriceList_Version_ID)"
+ " INNER JOIN M_Pricelist pl ON (pv.M_PriceList_ID=pl.M_PriceList_ID) "
+ "WHERE pv.IsActive='Y'"
+ " AND pp.IsActive='Y'"
+ " AND p.M_Product_ID=?"
+ " AND pv.M_PriceList_Version_ID=?";
The bomPriceStd(), bomPriceList(), and bomPriceLimit() are database functions that calculate prices for Bill of Material products by summing the component prices. For non-BOM products, they return the direct product price.
4.5 Context Setters
Before calling calculatePrice(), you typically set context information that controls which price list version is used:
// Set the price list (the engine will find the best version)
void setM_PriceList_ID(int M_PriceList_ID);
// Set a specific price list version (overrides version selection)
void setM_PriceList_Version_ID(int M_PriceList_Version_ID);
// Set the date for version selection (defaults to current date)
void setPriceDate(Timestamp priceDate);
// Set document line context (extracts product, partner, qty, etc.)
void setOrderLine(I_C_OrderLine orderLine, String trxName);
void setInvoiceLine(I_C_InvoiceLine invoiceLine, String trxName);
void setRequisitionLine(I_M_RequisitionLine reqLine, String trxName);
The setOrderLine() method is the most convenient way to configure the pricing engine from an order line context — it extracts the product, business partner, quantity, and other relevant fields automatically.
4.6 Price and Info Getters
// Price getters
BigDecimal getPriceStd(); // The standard/negotiated price
BigDecimal getPriceList(); // The catalog/list price
BigDecimal getPriceLimit(); // The floor (SO) or ceiling (PO) price
BigDecimal getDiscount(); // Discount percentage from schema
// Context getters
int getC_UOM_ID(); // Unit of Measure from price record
int getC_Currency_ID(); // Currency from price list
int getM_PriceList_Version_ID(); // Resolved version ID
// Status flags
boolean isEnforcePriceLimit(); // Is price limit enforcement active?
boolean isDiscountSchema(); // Was a discount schema applied?
boolean isCalculated(); // Was a price successfully found?
boolean isTaxIncluded(); // Does the price list include tax?
4.7 Pricing Flow for a Purchase Order Line
Here is the complete pricing flow when a product is set on a PO line:
- User selects a product on the order line.
MOrderLine.setPrice()is called (via callout or programmatically).setPrice()callsgetProductPricing(m_M_PriceList_ID)with the order’s purchase price list.getProductPricing()creates anIProductPricinginstance viaCore.getProductPricing().- It calls
setOrderLine(this, trxName)to configure the engine with line context. - It calls
setM_PriceList_ID(M_PriceList_ID)to specify the purchase price list. - It calls
calculatePrice(), which cascades through the resolution strategies. - If vendor break records exist and match the quantity, those prices are used.
- Otherwise, the standard
M_ProductPricerecord for the current price list version is used. - If a discount schema is attached to the business partner, it adjusts the resolved prices.
- Back in
setPrice(), the resolvedPriceStdbecomesPriceActual, andPriceListandPriceLimitare set on the line. - The discount percentage is calculated and stored.
- The UOM is set from the pricing engine if not already specified.
5. Document Action Workflow
Purchase Orders in iDempiere follow the standard DocAction lifecycle. The DocAction interface (org.compiere.process.DocAction) defines the constants and methods that all document types must implement.
5.1 DocAction Constants
The action constants represent operations a user can request on a document:
public interface DocAction
{
/** Complete = CO */
public static final String ACTION_Complete = "CO";
/** Wait Complete = WC */
public static final String ACTION_WaitComplete = "WC";
/** Approve = AP */
public static final String ACTION_Approve = "AP";
/** Reject = RJ */
public static final String ACTION_Reject = "RJ";
/** Post = PO */
public static final String ACTION_Post = "PO";
/** Void = VO */
public static final String ACTION_Void = "VO";
/** Close = CL */
public static final String ACTION_Close = "CL";
/** Reverse - Correct = RC */
public static final String ACTION_Reverse_Correct = "RC";
/** Reverse - Accrual = RA */
public static final String ACTION_Reverse_Accrual = "RA";
/** ReActivate = RE */
public static final String ACTION_ReActivate = "RE";
/** None = -- */
public static final String ACTION_None = "--";
/** Prepare = PR */
public static final String ACTION_Prepare = "PR";
/** Unlock = XL */
public static final String ACTION_Unlock = "XL";
/** Invalidate = IN */
public static final String ACTION_Invalidate = "IN";
// ...
}
5.2 Status Constants
The status constants represent the current state of a document:
/** Drafted = DR */
public static final String STATUS_Drafted = "DR";
/** Completed = CO */
public static final String STATUS_Completed = "CO";
/** Approved = AP */
public static final String STATUS_Approved = "AP";
/** Invalid = IN */
public static final String STATUS_Invalid = "IN";
/** Not Approved = NA */
public static final String STATUS_NotApproved = "NA";
/** Voided = VO */
public static final String STATUS_Voided = "VO";
/** Reversed = RE */
public static final String STATUS_Reversed = "RE";
/** Closed = CL */
public static final String STATUS_Closed = "CL";
/** Unknown = ?? */
public static final String STATUS_Unknown = "??";
/** In Progress = IP */
public static final String STATUS_InProgress = "IP";
/** Waiting Payment = WP */
public static final String STATUS_WaitingPayment = "WP";
/** Waiting Confirmation = WC */
public static final String STATUS_WaitingConfirmation = "WC";
5.3 PO Document Lifecycle
The typical Purchase Order lifecycle follows this state machine:
┌──────────────────────────────────────────────────┐
│ │
▼ │
┌──────┐ Prepare ┌────────────┐ Complete ┌──────────┐ Close ┌────────┐
│ DR │─────────►│ IP │──────────►│ CO │──────►│ CL │
│Drafted│ │In Progress │ │Completed │ │ Closed │
└──────┘ └────────────┘ └──────────┘ └────────┘
│ │ │
│ Void │ Void │ Void
▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────┐
│ VO │ │ VO │ │ VO │
│Voided│ │Voided│ │Voided│
└──────┘ └──────┘ └──────┘
│
│ ReActivate
▼
┌────────────┐
│ IP │
│In Progress │
└────────────┘
| Transition | Action | Key Operations |
|---|---|---|
| DR to IP | prepareIt() |
Validate lines exist, check period open for POO, convert doc type, validate ASI, reserve stock, calculate taxes, create payment schedule |
| IP to CO | completeIt() |
Set definite document number, implicit approval, create counter documents, landed cost allocation (PO only), update over-receipt, set DocAction to Close |
| CO to CL | closeIt() |
Set remaining QtyOrdered to QtyDelivered, record QtyLostSales, unreserve stock, recalculate taxes |
| Any to VO | voidIt() |
Reverse MatchPO records, zero out quantities, delete accounting entries, unlink requisitions |
| CO to IP | reActivateIt() |
For POs: simply re-opens (existing documents not modified). Re-reserves stock. |
5.4 prepareIt() — Detailed Walkthrough
public String prepareIt()
{
log.info(toString());
m_processMsg = ModelValidationEngine.get().fireDocValidate(
this, ModelValidator.TIMING_BEFORE_PREPARE);
if (m_processMsg != null)
return DocAction.STATUS_Invalid;
MDocType dt = MDocType.get(getCtx(), getC_DocTypeTarget_ID());
// Check accounting period is open for POO
if (!MPeriod.isOpen(getCtx(), getDateAcct(), dt.getDocBaseType(), getAD_Org_ID()))
{
m_processMsg = "@PeriodClosed@";
return DocAction.STATUS_Invalid;
}
// Validate lines exist
MOrderLine[] lines = getLines(true, MOrderLine.COLUMNNAME_M_Product_ID);
if (lines.length == 0)
{
m_processMsg = "@NoLines@";
return DocAction.STATUS_Invalid;
}
// Convert DocType to Target
if (getC_DocType_ID() != getC_DocTypeTarget_ID())
{
// New or in Progress/Invalid
if (DOCSTATUS_Drafted.equals(getDocStatus())
|| DOCSTATUS_InProgress.equals(getDocStatus())
|| DOCSTATUS_Invalid.equals(getDocStatus())
|| getC_DocType_ID() == 0)
{
setC_DocType_ID(getC_DocTypeTarget_ID());
}
}
// Mandatory Product Attribute Set Instance
for (MOrderLine line : getLines()) {
if (line.getM_Product_ID() > 0
&& line.getM_AttributeSetInstance_ID() == 0) {
MProduct product = line.getProduct();
if (product.isASIMandatoryFor(null, isSOTrx())) {
// ... validation error ...
}
}
}
// Explode BOM products
if (explodeBOM())
lines = getLines(true, MOrderLine.COLUMNNAME_M_Product_ID);
// Reserve stock
if (!reserveStock(dt, lines))
{
m_processMsg = "Cannot reserve Stock";
return DocAction.STATUS_Invalid;
}
// Calculate tax total
if (!calculateTaxTotal())
{
m_processMsg = "Error calculating tax";
return DocAction.STATUS_Invalid;
}
// Create payment schedule (if applicable)
if (getGrandTotal().signum() != 0
&& (PAYMENTRULE_OnCredit.equals(getPaymentRule())
|| PAYMENTRULE_DirectDebit.equals(getPaymentRule())))
{
if (!createPaySchedule())
{
m_processMsg = "@ErrorPaymentSchedule@";
return DocAction.STATUS_Invalid;
}
}
// Fire after-prepare validation
m_processMsg = ModelValidationEngine.get().fireDocValidate(
this, ModelValidator.TIMING_AFTER_PREPARE);
if (m_processMsg != null)
return DocAction.STATUS_Invalid;
m_justPrepared = true;
if (!DOCACTION_Complete.equals(getDocAction()))
setDocAction(DOCACTION_Complete);
return DocAction.STATUS_InProgress;
}
For Purchase Orders, the prepareIt() method performs these PO-relevant operations:
- Validates the accounting period is open for
POOdocument base type. - Ensures at least one order line exists.
- Converts the target document type to the actual document type.
- Checks mandatory Attribute Set Instances (ASI) — particularly important for lot/serial-tracked purchase items.
- Explodes BOM products into their component lines if applicable.
- Reserves stock — for POs, this creates “ordered” entries in
M_StorageReservation. - Calculates the tax total across all lines.
- Creates payment schedules if the payment rule requires them.
5.5 completeIt() — PO-Specific Behavior
The completeIt() method is heavily branched based on DocSubTypeSO. For Purchase Orders, most of the SO-specific logic is skipped. Here is the PO-relevant portion:
public String completeIt()
{
MDocType dt = MDocType.get(getCtx(), getC_DocType_ID());
String DocSubTypeSO = dt.getDocSubTypeSO();
// Just prepare
if (DOCACTION_Prepare.equals(getDocAction()))
{
setProcessed(false);
return DocAction.STATUS_InProgress;
}
// Set the definite document number after completed
setDefiniteDocumentNo();
// [SO-only: Proposals, Quotations, Prepay checks — skipped for PO]
// Re-Check preparation
if (!m_justPrepared)
{
String status = prepareIt();
m_justPrepared = false;
if (!DocAction.STATUS_InProgress.equals(status))
return status;
}
m_processMsg = ModelValidationEngine.get().fireDocValidate(
this, ModelValidator.TIMING_BEFORE_COMPLETE);
if (m_processMsg != null)
return DocAction.STATUS_Invalid;
// Implicit Approval
if (!isApproved())
approveIt();
getLines(true, null);
StringBuilder info = new StringBuilder();
// Counter Documents
MOrder counter = createCounterDoc();
if (counter != null)
info.append(" - @CounterDoc@: @Order@=").append(counter.getDocumentNo());
// [SO-only: Auto-generate shipment — skipped for PO]
// [SO-only: Auto-generate invoice — skipped for PO]
// [SO-only: POS payments — skipped for PO]
// User Validation
String valid = ModelValidationEngine.get().fireDocValidate(
this, ModelValidator.TIMING_AFTER_COMPLETE);
// *** PO-SPECIFIC: Landed Cost Allocation ***
if (!isSOTrx())
{
String error = landedCostAllocation();
if (!Util.isEmpty(error))
{
m_processMsg = error;
return DocAction.STATUS_Invalid;
}
}
updateOverReceipt();
setProcessed(true);
m_processMsg = info.toString();
setDocAction(DOCACTION_Close);
return DocAction.STATUS_Completed;
}
The critical PO-specific behavior at completion is the landedCostAllocation() call. This method distributes any landed costs (freight, duties, insurance) that have been defined on the order across the order lines:
protected String landedCostAllocation() {
MOrderLandedCost[] landedCosts = MOrderLandedCost.getOfOrder(
getC_Order_ID(), get_TrxName());
for (MOrderLandedCost landedCost : landedCosts) {
String error = landedCost.distributeLandedCost();
if (!Util.isEmpty(error))
return error;
}
return "";
}
5.6 voidIt() — Void a Purchase Order
public boolean voidIt()
{
log.info(toString());
// Before Void
m_processMsg = ModelValidationEngine.get().fireDocValidate(
this, ModelValidator.TIMING_BEFORE_VOID);
if (m_processMsg != null)
return false;
// Unlink linked SO if this is a drop-ship PO
if (getLink_Order_ID() > 0) {
MOrder so = new MOrder(getCtx(), getLink_Order_ID(), get_TrxName());
so.setLink_Order_ID(0);
so.saveEx();
}
// PO-specific: Reverse MatchPO records
if (isSOTrx()) {
if (!createReversals())
return false;
} else {
if (!createPOReversals()) // Reverses M_MatchPO entries
return false;
}
// Zero out all line quantities
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());
}
// PO-specific: Delete MatchPO cost details
if (!isSOTrx())
{
deleteMatchPOCostDetail(line);
}
// Unlink linked lines
if (line.getLink_OrderLine_ID() > 0) {
MOrderLine soline = new MOrderLine(
getCtx(), line.getLink_OrderLine_ID(), get_TrxName());
soline.setLink_OrderLine_ID(0);
soline.saveEx();
}
}
// 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());
// Delete accounting entries
MFactAcct.deleteEx(MOrder.Table_ID, getC_Order_ID(), get_TrxName());
setPosted(false);
// After Void
m_processMsg = ModelValidationEngine.get().fireDocValidate(
this, ModelValidator.TIMING_AFTER_VOID);
if (m_processMsg != null)
return false;
setTotalLines(Env.ZERO);
setGrandTotal(Env.ZERO);
setProcessed(true);
setDocAction(DOCACTION_None);
return true;
}
The PO-specific void operations are:
createPOReversals(): Reverses allM_MatchPOrecords associated with this PO’s lines. This undoes the three-way match between PO, receipt, and invoice.deleteMatchPOCostDetail(line): Removes cost detail records created during matching.MRequisitionLine.unlinkC_Order_ID(): Clears theC_Order_IDreference on any requisition lines that were linked to this PO, allowing them to be re-assigned to a new PO.
5.7 closeIt() — Close a Purchase Order
public boolean closeIt()
{
log.info(toString());
// Before Close
m_processMsg = ModelValidationEngine.get().fireDocValidate(
this, ModelValidator.TIMING_BEFORE_CLOSE);
if (m_processMsg != null)
return false;
// Validate no In-Progress shipments/receipts exist
// ... SQL check for DocStatus='IP' M_InOut records ...
// Close Not delivered Qty - SO/PO
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.compareTo(line.getQtyDelivered()) != 0)
{
if (line.getQtyOrdered().compareTo(line.getQtyDelivered()) > 0)
{
// Record lost quantity
line.setQtyLostSales(
line.getQtyOrdered().subtract(line.getQtyDelivered()));
// Set ordered = delivered (close the gap)
line.setQtyOrdered(line.getQtyDelivered());
}
else
{
line.setQtyLostSales(Env.ZERO);
}
line.addDescription("Close (" + old + ")");
line.saveEx(get_TrxName());
}
}
// Clear Reservations
if (!reserveStock(null, lines))
{
m_processMsg = "Cannot unreserve Stock (close)";
return false;
}
setProcessed(true);
setDocAction(DOCACTION_None);
// Recalculate tax totals
if (!calculateTaxTotal()) {
m_processMsg = "Error calculating tax";
return false;
}
// After Close
m_processMsg = ModelValidationEngine.get().fireDocValidate(
this, ModelValidator.TIMING_AFTER_CLOSE);
if (m_processMsg != null)
return false;
return true;
}
The closeIt() method is identical for POs and SOs. It sets the ordered quantity to the delivered quantity, records the difference as QtyLostSales, and unreserves any remaining stock. This is a clean way to end a PO that has been partially fulfilled — the “lost” quantity can be reported on for procurement analysis.
5.8 reActivateIt() — PO vs SO Behavior
public boolean reActivateIt()
{
// ... validation ...
MDocType dt = MDocType.get(getCtx(), getC_DocType_ID());
String DocSubTypeSO = dt.getDocSubTypeSO();
// PO - just re-open
if (!isSOTrx()) {
log.info("Existing documents not modified - " + dt);
// Reverse Direct Documents (SO only)
} else if (MDocType.DOCSUBTYPESO_OnCreditOrder.equals(DocSubTypeSO)
|| MDocType.DOCSUBTYPESO_WarehouseOrder.equals(DocSubTypeSO)
|| MDocType.DOCSUBTYPESO_POSOrder.equals(DocSubTypeSO))
{
if (!createReversals())
return false;
}
// ... re-reserve stock, reset processed flag ...
}
For Purchase Orders, reactivation is simpler than for Sales Orders — existing receipts and invoices are not reversed. The PO is simply put back into an editable state. This makes sense because vendor receipts and invoices represent external events that cannot be “un-done” by reactivating the PO.
6. Complete Code Examples
The following examples demonstrate common Purchase Order programming patterns using the classes we have studied. All examples assume execution within an iDempiere plugin or process context with a valid Properties ctx and transaction name.
6.1 Creating a Purchase Order from Scratch
/**
* Create a complete Purchase Order with multiple lines.
*
* @param ctx iDempiere context
* @param trxName transaction name
* @return the completed MOrder, or null on failure
*/
public MOrder createPurchaseOrder(Properties ctx, String trxName) {
// Step 1: Create the PO header
MOrder po = new MOrder(ctx, 0, trxName);
// CRITICAL: Set IsSOTrx BEFORE setting the business partner
po.setIsSOTrx(false);
// Set organization and warehouse
po.setAD_Org_ID(11); // e.g., HQ organization
po.setM_Warehouse_ID(103); // e.g., HQ Warehouse
// Set target document type (auto-selects POO)
po.setC_DocTypeTarget_ID();
// Set the vendor — this resolves PO_PriceList_ID and PO_PaymentTerm_ID
MBPartner vendor = MBPartner.get(ctx, 114); // e.g., Patio Furniture vendor
po.setBPartner(vendor);
// Set order dates
Timestamp today = new Timestamp(System.currentTimeMillis());
po.setDateOrdered(today);
po.setDatePromised(today);
// Save the header (required before adding lines)
po.saveEx(trxName);
// Step 2: Add order lines
// Line 1: 100 units of Product A
MOrderLine line1 = new MOrderLine(po);
line1.setM_Product_ID(123, true); // true = also set UOM from product
line1.setQty(new BigDecimal("100"));
line1.setPrice(); // Resolves price from vendor price list
line1.setTax(); // Resolves applicable tax
line1.saveEx(trxName);
// Line 2: 50 units of Product B with manual price override
MOrderLine line2 = new MOrderLine(po);
line2.setM_Product_ID(456, true);
line2.setQty(new BigDecimal("50"));
line2.setPrice(); // First resolve from price list
line2.setPriceActual(new BigDecimal("25.50")); // Then override
line2.setTax();
line2.saveEx(trxName);
// Line 3: 200 units of Product C with specific warehouse
MOrderLine line3 = new MOrderLine(po);
line3.setM_Product_ID(789, true);
line3.setM_Warehouse_ID(104); // Different warehouse for this line
line3.setQty(new BigDecimal("200"));
line3.setPrice();
line3.setTax();
line3.saveEx(trxName);
return po;
}
Important sequence:
setIsSOTrx(false)— must come firstsetC_DocTypeTarget_ID()— uses IsSOTrx to find POO doc typesetBPartner(vendor)— uses IsSOTrx to resolve PO price list and payment termsaveEx()— saves the header, generating the order ID- Create
MOrderLine(po)— requires saved parent setM_Product_ID(),setQty(),setPrice(),setTax(),saveEx()— for each line
6.2 Price Resolution from Vendor Price List
/**
* Demonstrate standalone price resolution for a purchase scenario.
* This shows how to use MProductPricing outside of an order context.
*
* @param ctx iDempiere context
* @param trxName transaction name
*/
public void resolvePurchasePrice(Properties ctx, String trxName) {
int M_Product_ID = 123; // The product to price
int C_BPartner_ID = 114; // The vendor
BigDecimal qty = new BigDecimal("500"); // Quantity for break pricing
boolean isSOTrx = false; // Purchase transaction
// Create pricing engine
MProductPricing pricing = new MProductPricing(
M_Product_ID, C_BPartner_ID, qty, isSOTrx, trxName);
// Set the purchase price list
// (normally obtained from vendor BPartner's PO_PriceList_ID)
MBPartner vendor = MBPartner.get(ctx, C_BPartner_ID);
int poPriceListId = vendor.getPO_PriceList_ID();
pricing.setM_PriceList_ID(poPriceListId);
// Optionally set a specific date for version selection
pricing.setPriceDate(new Timestamp(System.currentTimeMillis()));
// Calculate!
boolean found = pricing.calculatePrice();
if (found) {
System.out.println("Price found!");
System.out.println(" Standard Price: " + pricing.getPriceStd());
System.out.println(" List Price: " + pricing.getPriceList());
System.out.println(" Limit Price: " + pricing.getPriceLimit());
System.out.println(" Discount %: " + pricing.getDiscount());
System.out.println(" Currency ID: " + pricing.getC_Currency_ID());
System.out.println(" UOM ID: " + pricing.getC_UOM_ID());
System.out.println(" PLV ID: " + pricing.getM_PriceList_Version_ID());
System.out.println(" Vendor Break? " + pricing.isDiscountSchema());
System.out.println(" Tax Included? " + pricing.isTaxIncluded());
System.out.println(" Enforce Limit? " + pricing.isEnforcePriceLimit());
} else {
System.out.println("Product not found on price list!");
}
}
6.3 Processing a PO Through the Complete Lifecycle
/**
* Demonstrate the full document action lifecycle for a Purchase Order.
*
* @param po an existing MOrder (Purchase Order) in Drafted status
* @param trxName transaction name
*/
public void processPurchaseOrder(MOrder po, String trxName) {
// Verify it's a PO and in Draft status
if (po.isSOTrx()) {
throw new AdempiereException("Not a Purchase Order");
}
if (!DocAction.STATUS_Drafted.equals(po.getDocStatus())) {
throw new AdempiereException(
"Order must be in Drafted status, current: " + po.getDocStatus());
}
// Step 1: Prepare
// This validates lines, reserves stock, calculates taxes
po.setDocAction(DocAction.ACTION_Prepare);
if (!po.processIt(DocAction.ACTION_Prepare)) {
throw new AdempiereException(
"Prepare failed: " + po.getProcessMsg());
}
po.saveEx(trxName);
// Status is now IP (In Progress)
System.out.println("After Prepare: " + po.getDocStatus()); // "IP"
// Step 2: Complete
// For PO: runs landedCostAllocation(), creates counter docs
po.setDocAction(DocAction.ACTION_Complete);
if (!po.processIt(DocAction.ACTION_Complete)) {
throw new AdempiereException(
"Complete failed: " + po.getProcessMsg());
}
po.saveEx(trxName);
// Status is now CO (Completed)
System.out.println("After Complete: " + po.getDocStatus()); // "CO"
// At this point, the PO is ready for:
// - Material Receipt (M_InOut with M_InOutLine linked to C_OrderLine)
// - Vendor Invoice matching
// - Three-way matching via M_MatchPO
// Step 3: Close (when all expected receipts are in)
// This sets QtyOrdered = QtyDelivered, records QtyLostSales
po.setDocAction(DocAction.ACTION_Close);
if (!po.processIt(DocAction.ACTION_Close)) {
throw new AdempiereException(
"Close failed: " + po.getProcessMsg());
}
po.saveEx(trxName);
// Status is now CL (Closed)
System.out.println("After Close: " + po.getDocStatus()); // "CL"
}
/**
* Demonstrate voiding a Purchase Order.
*
* @param po an existing MOrder (Purchase Order)
* @param trxName transaction name
*/
public void voidPurchaseOrder(MOrder po, String trxName) {
// Void reverses MatchPO records, zeros quantities,
// unlinks requisitions, deletes accounting entries
po.setDocAction(DocAction.ACTION_Void);
if (!po.processIt(DocAction.ACTION_Void)) {
throw new AdempiereException(
"Void failed: " + po.getProcessMsg());
}
po.saveEx(trxName);
// Status is now VO (Voided)
System.out.println("After Void: " + po.getDocStatus()); // "VO"
// Verify requisition lines are unlinked
// MRequisitionLine.unlinkC_Order_ID() was called internally
}
6.4 Copying a Purchase Order
/**
* Create a new Purchase Order by copying an existing one.
* Useful for recurring purchases or creating a new PO from a template.
*
* @param ctx iDempiere context
* @param sourceOrderId C_Order_ID of the PO to copy
* @param trxName transaction name
* @return the new MOrder copy
*/
public MOrder copyPurchaseOrder(Properties ctx, int sourceOrderId, String trxName) {
// Load the source order
MOrder source = new MOrder(ctx, sourceOrderId, trxName);
if (source.get_ID() == 0) {
throw new AdempiereException("Source order not found: " + sourceOrderId);
}
if (source.isSOTrx()) {
throw new AdempiereException("Source is not a Purchase Order");
}
// Set the new order date
Timestamp newDate = new Timestamp(System.currentTimeMillis());
// Copy the order
// Parameters:
// from: source order
// dateDoc: new document date
// C_DocTypeTarget_ID: use same doc type as source
// isSOTrx: false (keep as PO)
// counter: false (not a counter document)
// copyASI: false (don't copy attribute set instances)
// trxName: transaction
MOrder copy = MOrder.copyFrom(
source,
newDate,
source.getC_DocTypeTarget_ID(),
false, // isSOTrx = false (Purchase Order)
false, // counter = false
false, // copyASI = false
trxName
);
// The copy is in Drafted status with:
// - New document number (auto-generated)
// - New dates (set to newDate)
// - All quantities reset: QtyDelivered=0, QtyInvoiced=0, QtyReserved=0
// - QtyOrdered and QtyEntered copied from source
// - Prices copied from source
// - Link_Order_ID cleared
System.out.println("Created PO copy: " + copy.getDocumentNo());
System.out.println(" Lines copied: " + copy.getLines().length);
System.out.println(" Status: " + copy.getDocStatus()); // "DR"
// Optionally modify the copy before processing
// For example, update the promised date:
Timestamp promisedDate = TimeUtil.addDays(newDate, 14);
copy.setDatePromised(promisedDate);
copy.saveEx(trxName);
return copy;
}
/**
* Copy specific lines from one PO to another existing PO.
*
* @param targetOrder the target MOrder to copy lines into
* @param sourceOrder the source MOrder to copy lines from
* @param trxName transaction name
* @return number of lines copied
*/
public int copyLinesToExistingPO(MOrder targetOrder, MOrder sourceOrder,
String trxName) {
if (targetOrder.isProcessed()) {
throw new AdempiereException("Target order is already processed");
}
// copyLinesFrom copies all lines, resets delivery/invoice quantities,
// and performs UOM conversion on entered quantities
int linesCopied = targetOrder.copyLinesFrom(
sourceOrder,
false, // counter = false
false // copyASI = false
);
System.out.println("Copied " + linesCopied + " lines from "
+ sourceOrder.getDocumentNo() + " to " + targetOrder.getDocumentNo());
return linesCopied;
}
6.5 Working with PO Matching Queries
/**
* Find Purchase Order lines that need to be matched to receipts.
* Uses the matching SQL constants defined in MOrder.
*
* @param ctx iDempiere context
* @param trxName transaction name
*/
public void findUnmatchedPOLines(Properties ctx, String trxName) {
int vendorId = 114; // Filter by vendor (0 for all)
int productId = 0; // No product filter
int inOutLineId = 0; // No specific receipt line
// Date range for the search
Timestamp fromDate = TimeUtil.addDays(
new Timestamp(System.currentTimeMillis()), -90); // Last 90 days
Timestamp toDate = new Timestamp(System.currentTimeMillis());
// Find PO lines NOT fully matched to receipts
List<MOrder.MatchingRecord> unmatched =
MOrder.getNotFullyMatchedToReceipt(
vendorId, productId, inOutLineId,
fromDate, toDate, trxName);
System.out.println("PO lines awaiting receipt matching: " + unmatched.size());
for (MOrder.MatchingRecord rec : unmatched) {
BigDecimal outstanding = rec.qtyOrdered().subtract(rec.matchedQty());
System.out.println(String.format(
" PO %s, Line %d: %s - Ordered: %s, Matched: %s, Outstanding: %s",
rec.documentNo(), rec.line(), rec.productName(),
rec.qtyOrdered(), rec.matchedQty(), outstanding));
}
// Find PO lines NOT fully matched to invoices
List<MOrder.MatchingRecord> unmatchedInvoice =
MOrder.getNotFullyMatchedToInvoice(
vendorId, productId, 0,
fromDate, toDate, trxName);
System.out.println("\nPO lines awaiting invoice matching: "
+ unmatchedInvoice.size());
for (MOrder.MatchingRecord rec : unmatchedInvoice) {
BigDecimal outstanding = rec.qtyOrdered().subtract(rec.matchedQty());
System.out.println(String.format(
" PO %s, Line %d: %s - Ordered: %s, Invoiced: %s, Outstanding: %s",
rec.documentNo(), rec.line(), rec.productName(),
rec.qtyOrdered(), rec.matchedQty(), outstanding));
}
}
6.6 Programmatic PO Line Price Adjustment
/**
* Demonstrate adjusting prices on an existing drafted PO.
* This is useful when negotiating with a vendor after initial PO creation.
*
* @param po an existing MOrder in Drafted status
* @param trxName transaction name
*/
public void adjustPOPrices(MOrder po, String trxName) {
if (!DocAction.STATUS_Drafted.equals(po.getDocStatus())
&& !DocAction.STATUS_InProgress.equals(po.getDocStatus())) {
throw new AdempiereException("Order must be in Draft or In Progress");
}
MOrderLine[] lines = po.getLines(true, null);
for (MOrderLine line : lines) {
if (line.getM_Product_ID() == 0)
continue;
// Apply a 5% discount to all lines
BigDecimal currentPrice = line.getPriceActual();
BigDecimal discountMultiplier = new BigDecimal("0.95");
BigDecimal newPrice = currentPrice.multiply(discountMultiplier)
.setScale(line.getPrecision(), RoundingMode.HALF_UP);
line.setPriceActual(newPrice);
line.setPriceEntered(newPrice);
// Recalculate discount percentage
line.setDiscount();
// Recalculate line net amount
line.setLineNetAmt();
line.saveEx(trxName);
System.out.println(String.format(
" Line %d: %s -> %s (%.2f%% discount)",
line.getLine(), currentPrice, newPrice,
line.getDiscount()));
}
// Recalculate order totals
po.calculateTaxTotal();
po.saveEx(trxName);
System.out.println("New Grand Total: " + po.getGrandTotal());
}
7. Summary
This lesson provided a deep dive into the Java source code that powers Purchase Orders in iDempiere. Here is a recap of the key concepts:
Core Architecture
| Concept | Detail |
|---|---|
| Shared table | POs and SOs both use C_Order / C_OrderLine, differentiated by IsSOTrx |
| Document Base Type | POO for Purchase Orders, defined in C_DocType |
| Model class | MOrder extends X_C_Order implements DocAction |
| Line class | MOrderLine extends X_C_OrderLine |
| Pricing engine | MProductPricing implements IProductPricing, resolves from M_PriceList hierarchy |
Critical Code Patterns
- Always set
IsSOTrxfirst: CallsetIsSOTrx(false)beforesetBPartner()orsetC_DocTypeTarget_ID()— these methods branch on the flag. - Save header before adding lines:
MOrderLine(MOrder order)requires the parent to have a valid ID. - Price resolution cascade: Vendor break pricing is checked first, then standard pricing, then base price list — each with version-specific, list-specific, and base-specific variants.
- Quantity tracking:
QtyOrdered(in product UOM),QtyEntered(in entered UOM),QtyDelivered(receipts),QtyInvoiced(invoice matching),QtyReserved(stock reservation),QtyLostSales(closed shortfall).
PO-Specific Behaviors
| Operation | PO-Specific Behavior |
|---|---|
setC_DocTypeTarget_ID() |
Queries for DocBaseType='POO' |
setBPartner() |
Resolves PO_PriceList_ID, PO_PaymentTerm_ID, PaymentRulePO |
completeIt() |
Calls landedCostAllocation(); skips SO auto-shipment/invoice generation |
voidIt() |
Calls createPOReversals() for MatchPO; calls MRequisitionLine.unlinkC_Order_ID() |
reActivateIt() |
Simply re-opens without reversing receipts or invoices |
closeIt() |
Same as SO: sets QtyOrdered = QtyDelivered, records QtyLostSales |
| Matching | Static SQL constants and methods for three-way matching (PO to Receipt, PO to Invoice) |
Document Lifecycle Summary
| Status | Code | What Can Happen Next |
|---|---|---|
| Drafted | DR | Edit freely, Prepare, Void |
| In Progress | IP | Complete, Void |
| Completed | CO | Create Receipts, Match Invoices, Close, Void, ReActivate |
| Closed | CL | Re-open (via reopenIt()) |
| Voided | VO | No further actions (terminal state) |
Key Source Files Reference
| Class | Package | Role |
|---|---|---|
MOrder |
org.compiere.model |
Order header model with DocAction implementation |
MOrderLine |
org.compiere.model |
Order line model with pricing and tax integration |
MProductPricing |
org.compiere.model |
Default pricing engine implementation |
IProductPricing |
org.adempiere.base |
Pricing engine interface (for custom implementations) |
AbstractProductPricing |
org.adempiere.base |
Base class with common pricing logic |
DocAction |
org.compiere.process |
Interface defining document action and status constants |
DocumentEngine |
org.compiere.process |
State machine that drives document processing |
MMatchPO |
org.compiere.model |
Three-way matching between PO, Receipt, and Invoice |
MOrderLandedCost |
org.compiere.model |
Landed cost allocation on PO completion |
What’s Next
In the next lesson, we will study Material Receipts (M_InOut with MovementType = Vendor Receipt) — the document that records the physical arrival of goods ordered via a Purchase Order. We will examine how MInOut and MInOutLine link back to C_OrderLine, how inventory is updated through MStorageOnHand, and how the M_MatchPO records are created to begin the three-way matching process.