Purchase Orders

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

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 POO documents during prepareIt().
  • Document numbering: The document sequence assigned to the POO document type generates the Purchase Order number.
  • Accounting: The posting logic uses POO to locate the correct GL accounts for purchase order commitments.
  • Matching: The matching SQL constants in MOrder explicitly filter on DocBaseType='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 MOrder fires for both POs and SOs. You must check isSOTrx() if your logic should apply only to one type.
  • Callouts: Callouts registered on the C_Order window apply to both the Purchase Order window and the Sales Order window. The IsSOTrx context variable distinguishes them.
  • Reports and Queries: Any SQL query against C_Order that does not filter on IsSOTrx or DocBaseType will return both POs and SOs — a common source of bugs in custom reports.
  • Customizations: Adding a column to C_Order adds 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_DocType for rows where DocBaseType='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 DESC clause 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 DocSubTypeSO filtering — 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 PriceActual on the order line.
  • PriceLimit: The maximum price you are willing to pay. If EnforcePriceLimit is 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:

  1. User selects a product on the order line.
  2. MOrderLine.setPrice() is called (via callout or programmatically).
  3. setPrice() calls getProductPricing(m_M_PriceList_ID) with the order’s purchase price list.
  4. getProductPricing() creates an IProductPricing instance via Core.getProductPricing().
  5. It calls setOrderLine(this, trxName) to configure the engine with line context.
  6. It calls setM_PriceList_ID(M_PriceList_ID) to specify the purchase price list.
  7. It calls calculatePrice(), which cascades through the resolution strategies.
  8. If vendor break records exist and match the quantity, those prices are used.
  9. Otherwise, the standard M_ProductPrice record for the current price list version is used.
  10. If a discount schema is attached to the business partner, it adjusts the resolved prices.
  11. Back in setPrice(), the resolved PriceStd becomes PriceActual, and PriceList and PriceLimit are set on the line.
  12. The discount percentage is calculated and stored.
  13. 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:

  1. Validates the accounting period is open for POO document base type.
  2. Ensures at least one order line exists.
  3. Converts the target document type to the actual document type.
  4. Checks mandatory Attribute Set Instances (ASI) — particularly important for lot/serial-tracked purchase items.
  5. Explodes BOM products into their component lines if applicable.
  6. Reserves stock — for POs, this creates “ordered” entries in M_StorageReservation.
  7. Calculates the tax total across all lines.
  8. 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 all M_MatchPO records 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 the C_Order_ID reference 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:

  1. setIsSOTrx(false) — must come first
  2. setC_DocTypeTarget_ID() — uses IsSOTrx to find POO doc type
  3. setBPartner(vendor) — uses IsSOTrx to resolve PO price list and payment term
  4. saveEx() — saves the header, generating the order ID
  5. Create MOrderLine(po) — requires saved parent
  6. 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 IsSOTrx first: Call setIsSOTrx(false) before setBPartner() or setC_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.

You Missed