Vendor Evaluation & Scoring

Level: Advanced Module: Procurement 52 min read Lesson 33 of 55

Overview

  • What you’ll learn:
    • The iDempiere data model for vendor evaluation, centered on the M_Product_PO table and its fields
    • How to analyze delivery performance using M_InOut (Material Receipt) and C_Order (Purchase Order) data
    • Quality tracking through vendor receipt and vendor return movement types
    • Pricing analysis across multiple vendors using PriceLastPO, PriceLastInv, and related fields
    • Building a custom vendor scorecard process using the SvrProcess framework
    • Implementing a ModelValidator for real-time, event-driven vendor rating updates
    • Practical SQL queries and full Java code examples grounded in real iDempiere source code
  • Prerequisites: Lesson 4 — Vendor Management Basics
  • Estimated reading time: 25 minutes

1. Introduction: Why Vendor Evaluation Matters

In any procurement operation, the vendors you select directly determine your product quality, delivery reliability, and cost structure. Without a systematic vendor evaluation process, purchasing decisions devolve into gut feelings and personal relationships rather than data-driven analysis. iDempiere provides a rich set of data structures and extensibility points that make it possible to build a comprehensive vendor evaluation system entirely within the ERP platform.

The Business Case for Systematic Vendor Scoring

Consider the operational costs of a poorly performing vendor: late deliveries cause production stoppages, quality defects generate returns and rework, and hidden price increases erode margins. A vendor evaluation system transforms these scattered problems into measurable, comparable metrics. When every vendor has a score, procurement teams can negotiate from a position of data, identify underperformers before they cause disruptions, and allocate volume to the vendors who consistently deliver value.

In iDempiere, several key tables and relationships provide the raw data for vendor evaluation:

Data Source Table Name What It Provides
Vendor-Product Relationship M_Product_PO Quality rating, delivery times, pricing, vendor product mapping
Purchase Orders C_Order (where IsSOTrx='N') Promised delivery dates, ordered quantities, order pricing
Material Receipts M_InOut (MovementType V+) Actual receipt dates, received quantities
Vendor Returns M_InOut (MovementType V-) Quality rejections, return quantities
Match PO Records M_MatchPO Links between PO lines, receipt lines, and invoice lines
Vendor Master C_BPartner Vendor status, grouping, payment terms, price lists

Manual vs. Automated Approaches

iDempiere supports both approaches. You can perform manual evaluation using the built-in QualityRating field on M_Product_PO, where a procurement officer simply enters a score from 0 to 100. This is simple but subjective and prone to neglect. The automated approach, which this lesson teaches in detail, involves building processes and model validators that calculate scores from actual transactional data — purchase orders, receipts, returns, and invoices. The automated approach ensures scores are always current, objective, and based on the same criteria across all vendors.

2. MProductPO Evaluation Fields Deep Dive

The M_Product_PO table is the cornerstone of vendor evaluation in iDempiere. It represents the relationship between a specific product and a specific vendor (business partner). The Java model class MProductPO extends the generated class X_M_Product_PO, which in turn extends PO (the base persistence object). The interface I_M_Product_PO defines all column name constants.

Here is the class hierarchy from the source code:

// From MProductPO.java
public class MProductPO extends X_M_Product_PO {
    private static final long serialVersionUID = -1883198806060209516L;
    // ...
}

// From X_M_Product_PO.java (generated model)
@org.adempiere.base.Model(table="M_Product_PO")
public class X_M_Product_PO extends PO implements I_M_Product_PO, I_Persistent {
    private static final long serialVersionUID = 20250805L;
    // ...
}

// Table constants from I_M_Product_PO.java
public static final String Table_Name = "M_Product_PO";
public static final int Table_ID = 210;

Complete Field Reference

The following table documents every column in M_Product_PO that is relevant to vendor evaluation, drawn directly from the I_M_Product_PO interface and X_M_Product_PO generated class:

Column Name Java Constant Type Description
C_BPartner_ID COLUMNNAME_C_BPartner_ID int (FK) The vendor (business partner) for this product relationship
M_Product_ID COLUMNNAME_M_Product_ID int (FK) The product being supplied by this vendor
IsCurrentVendor COLUMNNAME_IsCurrentVendor boolean Whether this is the current (preferred) vendor for pricing and replenishment
QualityRating COLUMNNAME_QualityRating int Method for rating vendors (0-100 scale)
DeliveryTime_Promised COLUMNNAME_DeliveryTime_Promised int Promised days between order and delivery
DeliveryTime_Actual COLUMNNAME_DeliveryTime_Actual int Actual days between order and delivery
PriceLastPO COLUMNNAME_PriceLastPO BigDecimal Price of the last purchase order for the product (read-only, set by system)
PriceLastInv COLUMNNAME_PriceLastInv BigDecimal Price of the last invoice for the product (read-only, set by system)
PricePO COLUMNNAME_PricePO BigDecimal Standard PO price for the product from this vendor
PriceList COLUMNNAME_PriceList BigDecimal List price from the vendor
PriceEffective COLUMNNAME_PriceEffective Timestamp Effective date of the current price
CostPerOrder COLUMNNAME_CostPerOrder BigDecimal Fixed cost per order (shipping, handling, etc.)
RoyaltyAmt COLUMNNAME_RoyaltyAmt BigDecimal Included amount for copyright, licensing, etc.
Discontinued COLUMNNAME_Discontinued boolean Whether this product is no longer available from this vendor
DiscontinuedAt COLUMNNAME_DiscontinuedAt Timestamp Date when the product was discontinued
Order_Min COLUMNNAME_Order_Min BigDecimal Minimum order quantity in UOM
Order_Pack COLUMNNAME_Order_Pack BigDecimal Package order size in UOM (e.g., order set of 5 units)
C_Currency_ID COLUMNNAME_C_Currency_ID int (FK) Currency for this vendor’s pricing
C_UOM_ID COLUMNNAME_C_UOM_ID int (FK) Unit of Measure for this vendor’s product
VendorProductNo COLUMNNAME_VendorProductNo String Product key/SKU as used by the vendor
VendorCategory COLUMNNAME_VendorCategory String Product category of the business partner
Manufacturer COLUMNNAME_Manufacturer String Manufacturer of the product
UPC COLUMNNAME_UPC String Bar code (Universal Product Code or European Article Number)

The IsCurrentVendor Constraint

A critical business rule enforced in MProductPO.beforeSave() is that only one vendor can be the “current vendor” for a given product at any time. This constraint is enforced with a direct SQL check:

// From MProductPO.java — beforeSave()
@Override
protected boolean beforeSave(boolean newRecord)
{
    // Can only have one current vendor for a product
    if (isActive() && isCurrentVendor())
    {
        int cnt = DB.getSQLValue(get_TrxName(),
            "SELECT COUNT(*) FROM M_Product_PO "
            + "WHERE IsActive='Y' AND IsCurrentVendor='Y' "
            + "AND C_BPartner_ID!=? AND M_Product_ID=?",
            getC_BPartner_ID(), getM_Product_ID());
        if (cnt > 0) {
            log.saveError("SaveError",
                Msg.getMsg(getCtx(),
                    DBException.SAVE_ERROR_NOT_UNIQUE_MSG, true)
                + Msg.getElement(getCtx(), COLUMNNAME_IsCurrentVendor));
            return false;
        }
    }
    return true;
}

This means that if you want to switch the current vendor for a product, you must first unset the flag on the existing current vendor, or simply set the new one and rely on the validation to reject duplicates. Your evaluation process may need to update IsCurrentVendor based on scoring results, and it must respect this constraint.

The getOfProduct Static Method

To retrieve all vendor records for a product, MProductPO provides a static method that returns them sorted with the current vendor first:

// From MProductPO.java
public static MProductPO[] getOfProduct(Properties ctx, int M_Product_ID, String trxName)
{
    final String whereClause = "M_Product_ID=?";
    List<MProductPO> list = new Query(ctx, Table_Name, whereClause, trxName)
        .setParameters(M_Product_ID)
        .setOnlyActiveRecords(true)
        .setOrderBy("IsCurrentVendor DESC")
        .list();
    return list.toArray(new MProductPO[list.size()]);
}

This method is fundamental to any multi-vendor comparison process. It gives you every active vendor for a product, with the current vendor always at index 0.

Price Tracking: Read-Only System Fields

Two important fields are set by the system automatically and cannot be edited by users. In the generated class, both use set_ValueNoCheck, which prevents manual updates through the UI:

// From X_M_Product_PO.java — PriceLastPO (read-only)
public void setPriceLastPO(BigDecimal PriceLastPO)
{
    set_ValueNoCheck(COLUMNNAME_PriceLastPO, PriceLastPO);
}

// From X_M_Product_PO.java — PriceLastInv (read-only)
public void setPriceLastInv(BigDecimal PriceLastInv)
{
    set_ValueNoCheck(COLUMNNAME_PriceLastInv, PriceLastInv);
}

The system updates PriceLastPO when a purchase order is completed and PriceLastInv when a vendor invoice is completed. These provide an automatic audit trail of price changes over time, which is essential for price trend analysis in vendor evaluation.

3. Delivery Performance Analysis

Delivery performance is the most impactful dimension of vendor evaluation. A vendor who delivers late disrupts production schedules, ties up safety stock, and increases overall inventory costs. iDempiere captures all the data needed to measure delivery performance, spread across the C_Order and M_InOut tables.

The Data Model for Delivery Analysis

The key relationship is between a Purchase Order (C_Order where IsSOTrx='N') and its corresponding Material Receipt (M_InOut with MovementType='V+'). The receipt header stores a reference to the originating order via C_Order_ID. At the line level, the M_MatchPO table provides precise matching between order lines and receipt lines.

The critical date columns are:

Table Column Constant Purpose
C_Order DatePromised COLUMNNAME_DatePromised The date the vendor promised to deliver
C_Order DateOrdered COLUMNNAME_DateOrdered The date the PO was placed
M_InOut MovementDate COLUMNNAME_MovementDate The actual date goods were received
M_InOut DateReceived COLUMNNAME_DateReceived The date the receipt was acknowledged

On-Time Delivery Rate (OTD%)

The On-Time Delivery rate is the most fundamental vendor KPI. It measures the percentage of receipts that arrived on or before the promised date. Here is a query using actual iDempiere table and column names:

-- On-Time Delivery Rate by Vendor
-- Uses C_Order.DatePromised vs M_InOut.MovementDate
SELECT
    bp.C_BPartner_ID,
    bp.Name AS VendorName,
    COUNT(*) AS TotalDeliveries,
    SUM(CASE
        WHEN io.MovementDate <= o.DatePromised THEN 1
        ELSE 0
    END) AS OnTimeDeliveries,
    ROUND(
        SUM(CASE WHEN io.MovementDate <= o.DatePromised THEN 1 ELSE 0 END)
        * 100.0 / COUNT(*), 2
    ) AS OTD_Percent
FROM M_InOut io
    INNER JOIN C_BPartner bp ON io.C_BPartner_ID = bp.C_BPartner_ID
    INNER JOIN C_Order o ON io.C_Order_ID = o.C_Order_ID
    INNER JOIN C_DocType dt ON io.C_DocType_ID = dt.C_DocType_ID
WHERE io.MovementType = 'V+'           -- Vendor Receipts
    AND io.DocStatus IN ('CO', 'CL')   -- Completed or Closed
    AND o.IsSOTrx = 'N'                -- Purchase Orders only
    AND o.DatePromised IS NOT NULL
    AND io.MovementDate BETWEEN ? AND ?  -- Date range parameter
GROUP BY bp.C_BPartner_ID, bp.Name
ORDER BY OTD_Percent DESC;

Late Delivery Tracking with Severity

Not all late deliveries are equal. A delivery that is one day late is very different from one that is two weeks late. The following query segments late deliveries by severity:

-- Late Delivery Detail with Severity Buckets
SELECT
    bp.Name AS VendorName,
    o.DocumentNo AS PO_Number,
    io.DocumentNo AS Receipt_Number,
    o.DatePromised,
    io.MovementDate AS DateReceived,
    (io.MovementDate - o.DatePromised) AS DaysLate,
    CASE
        WHEN (io.MovementDate - o.DatePromised) BETWEEN 1 AND 3 THEN 'Minor (1-3 days)'
        WHEN (io.MovementDate - o.DatePromised) BETWEEN 4 AND 7 THEN 'Moderate (4-7 days)'
        WHEN (io.MovementDate - o.DatePromised) BETWEEN 8 AND 14 THEN 'Significant (8-14 days)'
        WHEN (io.MovementDate - o.DatePromised) > 14 THEN 'Critical (>14 days)'
    END AS Severity
FROM M_InOut io
    INNER JOIN C_BPartner bp ON io.C_BPartner_ID = bp.C_BPartner_ID
    INNER JOIN C_Order o ON io.C_Order_ID = o.C_Order_ID
WHERE io.MovementType = 'V+'
    AND io.DocStatus IN ('CO', 'CL')
    AND o.IsSOTrx = 'N'
    AND io.MovementDate > o.DatePromised
    AND io.MovementDate BETWEEN ? AND ?
ORDER BY (io.MovementDate - o.DatePromised) DESC;

Average Lead Time Computation

Lead time is the number of days from order placement (C_Order.DateOrdered) to goods receipt (M_InOut.MovementDate). This differs from on-time delivery, which compares against the promise date. Lead time analysis helps you understand how long a vendor actually takes to fulfill orders, independent of what they promise.

-- Average Lead Time by Vendor
SELECT
    bp.C_BPartner_ID,
    bp.Name AS VendorName,
    COUNT(*) AS TotalReceipts,
    AVG(io.MovementDate - o.DateOrdered) AS AvgLeadTimeDays,
    MIN(io.MovementDate - o.DateOrdered) AS MinLeadTimeDays,
    MAX(io.MovementDate - o.DateOrdered) AS MaxLeadTimeDays,
    STDDEV(io.MovementDate - o.DateOrdered) AS LeadTimeStdDev
FROM M_InOut io
    INNER JOIN C_BPartner bp ON io.C_BPartner_ID = bp.C_BPartner_ID
    INNER JOIN C_Order o ON io.C_Order_ID = o.C_Order_ID
WHERE io.MovementType = 'V+'
    AND io.DocStatus IN ('CO', 'CL')
    AND o.IsSOTrx = 'N'
    AND io.MovementDate BETWEEN ? AND ?
GROUP BY bp.C_BPartner_ID, bp.Name
ORDER BY AvgLeadTimeDays ASC;

Delivery Time Variance Analysis

Consistency matters as much as speed. A vendor who delivers in 5-7 days consistently is often preferable to one who averages 4 days but ranges from 1 to 15. The standard deviation in the query above captures this, but you can also compare DeliveryTime_Promised and DeliveryTime_Actual from the M_Product_PO table for a product-level perspective:

-- Delivery Time Variance from M_Product_PO
SELECT
    bp.Name AS VendorName,
    p.Name AS ProductName,
    po.DeliveryTime_Promised,
    po.DeliveryTime_Actual,
    (po.DeliveryTime_Actual - po.DeliveryTime_Promised) AS Variance,
    CASE
        WHEN po.DeliveryTime_Actual <= po.DeliveryTime_Promised THEN 'On Track'
        WHEN (po.DeliveryTime_Actual - po.DeliveryTime_Promised) <= 3 THEN 'Slightly Late'
        ELSE 'Significantly Late'
    END AS VarianceCategory
FROM M_Product_PO po
    INNER JOIN C_BPartner bp ON po.C_BPartner_ID = bp.C_BPartner_ID
    INNER JOIN M_Product p ON po.M_Product_ID = p.M_Product_ID
WHERE po.IsActive = 'Y'
    AND po.DeliveryTime_Promised > 0
    AND po.DeliveryTime_Actual > 0
ORDER BY (po.DeliveryTime_Actual - po.DeliveryTime_Promised) DESC;

4. Quality Metrics

Quality evaluation in iDempiere leverages the relationship between vendor receipts and vendor returns. When goods arrive from a vendor, they are received with a Vendor Receipt (MovementType='V+'). When defective goods are sent back, they are processed as a Vendor Return (MovementType='V-'). The ratio between these two movements provides a direct measure of vendor quality.

MInOut Movement Type Constants

The X_M_InOut class defines all movement type constants. The ones relevant to vendor evaluation are:

// From X_M_InOut.java — Movement Type constants
public static final int MOVEMENTTYPE_AD_Reference_ID = 189;

public static final String MOVEMENTTYPE_VendorReceipts = "V+";   // Goods received from vendor
public static final String MOVEMENTTYPE_VendorReturns = "V-";    // Goods returned to vendor

// Other movement types for reference:
public static final String MOVEMENTTYPE_CustomerShipment = "C-";  // Shipment to customer
public static final String MOVEMENTTYPE_CustomerReturns = "C+";   // Return from customer
public static final String MOVEMENTTYPE_InventoryIn = "I+";       // Inventory adjustment in
public static final String MOVEMENTTYPE_InventoryOut = "I-";      // Inventory adjustment out
public static final String MOVEMENTTYPE_MovementTo = "M+";        // Inventory move to
public static final String MOVEMENTTYPE_MovementFrom = "M-";      // Inventory move from
public static final String MOVEMENTTYPE_ProductionPlus = "P+";    // Production receipt
public static final String MOVEMENTTYPE_Production_ = "P-";       // Production issue
public static final String MOVEMENTTYPE_WorkOrderPlus = "W+";     // Work order receipt
public static final String MOVEMENTTYPE_WorkOrder_ = "W-";        // Work order issue

In the MInOut.completeIt() method, the movement type is read to determine how inventory is affected:

// From MInOut.java — completeIt() (line 1688)
String MovementType = getMovementType();
BigDecimal Qty = sLine.getMovementQty();

The checkMaterialPolicy() method in MInOut also uses movement types to determine processing logic. It uses the second character (+ or -) to determine direction:

// From MInOut.java — checkMaterialPolicy() (line 2366)
String MovementType = getMovementType();
boolean inTrx = MovementType.charAt(1) == '+';  // V+ = Vendor Receipt (incoming)

Quality Defect Rate Calculation

The defect rate is the ratio of returned quantity to received quantity. This query calculates it per vendor:

-- Quality Defect Rate by Vendor
-- Compares V+ (Vendor Receipts) to V- (Vendor Returns)
SELECT
    bp.C_BPartner_ID,
    bp.Name AS VendorName,
    COALESCE(recv.TotalReceived, 0) AS TotalQtyReceived,
    COALESCE(ret.TotalReturned, 0) AS TotalQtyReturned,
    CASE
        WHEN COALESCE(recv.TotalReceived, 0) = 0 THEN 0
        ELSE ROUND(
            COALESCE(ret.TotalReturned, 0) * 100.0
            / recv.TotalReceived, 2
        )
    END AS DefectRate_Percent
FROM C_BPartner bp
LEFT JOIN (
    -- Total received quantities (V+ movement type)
    SELECT
        io.C_BPartner_ID,
        SUM(iol.MovementQty) AS TotalReceived
    FROM M_InOut io
        INNER JOIN M_InOutLine iol ON io.M_InOut_ID = iol.M_InOut_ID
    WHERE io.MovementType = 'V+'
        AND io.DocStatus IN ('CO', 'CL')
        AND io.MovementDate BETWEEN ? AND ?
    GROUP BY io.C_BPartner_ID
) recv ON bp.C_BPartner_ID = recv.C_BPartner_ID
LEFT JOIN (
    -- Total returned quantities (V- movement type)
    SELECT
        io.C_BPartner_ID,
        SUM(ABS(iol.MovementQty)) AS TotalReturned
    FROM M_InOut io
        INNER JOIN M_InOutLine iol ON io.M_InOut_ID = iol.M_InOut_ID
    WHERE io.MovementType = 'V-'
        AND io.DocStatus IN ('CO', 'CL')
        AND io.MovementDate BETWEEN ? AND ?
    GROUP BY io.C_BPartner_ID
) ret ON bp.C_BPartner_ID = ret.C_BPartner_ID
WHERE bp.IsVendor = 'Y'
    AND bp.IsActive = 'Y'
    AND COALESCE(recv.TotalReceived, 0) > 0
ORDER BY DefectRate_Percent DESC;

Updating QualityRating on M_Product_PO

The QualityRating field on M_Product_PO is an integer that represents a quality score. In the generated model, its getter returns 0 when null:

// From X_M_Product_PO.java
/** Set Quality Rating.
    @param QualityRating Method for rating vendors */
public void setQualityRating(int QualityRating)
{
    set_Value(COLUMNNAME_QualityRating, Integer.valueOf(QualityRating));
}

/** Get Quality Rating.
    @return Method for rating vendors */
public int getQualityRating()
{
    Integer ii = (Integer)get_Value(COLUMNNAME_QualityRating);
    if (ii == null)
        return 0;
    return ii.intValue();
}

To programmatically update a vendor’s quality rating based on calculated defect rates:

/**
 * Update QualityRating for a vendor-product based on defect rate.
 * A 0% defect rate maps to 100 (perfect), a 100% defect rate maps to 0.
 */
public void updateQualityRating(Properties ctx, int C_BPartner_ID,
        int M_Product_ID, BigDecimal defectRate, String trxName) {
    // Load the vendor-product record
    final String whereClause = "C_BPartner_ID=? AND M_Product_ID=?";
    MProductPO productPO = new Query(ctx, I_M_Product_PO.Table_Name,
            whereClause, trxName)
        .setParameters(C_BPartner_ID, M_Product_ID)
        .setOnlyActiveRecords(true)
        .firstOnly();

    if (productPO != null) {
        // Convert defect rate to quality score: 100 - defect%
        int qualityScore = BigDecimal.valueOf(100)
            .subtract(defectRate)
            .max(BigDecimal.ZERO)
            .intValue();
        productPO.setQualityRating(qualityScore);
        productPO.saveEx();
    }
}

Quality Trend Over Time

To track quality trends, you can query receipts and returns month by month:

-- Monthly Quality Trend for a Specific Vendor
SELECT
    TO_CHAR(io.MovementDate, 'YYYY-MM') AS Month,
    SUM(CASE WHEN io.MovementType = 'V+' THEN iol.MovementQty ELSE 0 END) AS QtyReceived,
    SUM(CASE WHEN io.MovementType = 'V-' THEN ABS(iol.MovementQty) ELSE 0 END) AS QtyReturned,
    CASE
        WHEN SUM(CASE WHEN io.MovementType = 'V+' THEN iol.MovementQty ELSE 0 END) = 0 THEN 100
        ELSE ROUND(
            (1 - SUM(CASE WHEN io.MovementType = 'V-' THEN ABS(iol.MovementQty) ELSE 0 END)
            / SUM(CASE WHEN io.MovementType = 'V+' THEN iol.MovementQty ELSE 0 END)
            ) * 100, 2
        )
    END AS QualityScore
FROM M_InOut io
    INNER JOIN M_InOutLine iol ON io.M_InOut_ID = iol.M_InOut_ID
WHERE io.C_BPartner_ID = ?
    AND io.MovementType IN ('V+', 'V-')
    AND io.DocStatus IN ('CO', 'CL')
    AND io.MovementDate BETWEEN ? AND ?
GROUP BY TO_CHAR(io.MovementDate, 'YYYY-MM')
ORDER BY Month;

5. Pricing Analysis

Vendor pricing analysis in iDempiere draws on multiple fields in M_Product_PO as well as transactional data from purchase orders and invoices. The goal is to compare prices across vendors, track price trends over time, and calculate total cost of ownership.

Multi-Vendor Price Comparison

Since a product can have multiple records in M_Product_PO (one per vendor), a straightforward comparison query reveals price differences:

-- Multi-Vendor Price Comparison for All Products
SELECT
    p.M_Product_ID,
    p.Name AS ProductName,
    bp.Name AS VendorName,
    po.PricePO,
    po.PriceList,
    po.PriceLastPO,
    po.PriceLastInv,
    po.PriceEffective,
    po.CostPerOrder,
    po.RoyaltyAmt,
    po.IsCurrentVendor,
    po.Order_Min,
    po.Order_Pack,
    -- Total unit cost including per-order costs (amortized over min order qty)
    CASE
        WHEN po.Order_Min > 0 THEN
            po.PricePO + (po.CostPerOrder / po.Order_Min) + po.RoyaltyAmt
        ELSE
            po.PricePO + po.RoyaltyAmt
    END AS TotalUnitCost
FROM M_Product_PO po
    INNER JOIN M_Product p ON po.M_Product_ID = p.M_Product_ID
    INNER JOIN C_BPartner bp ON po.C_BPartner_ID = bp.C_BPartner_ID
WHERE po.IsActive = 'Y'
    AND po.Discontinued = 'N'
    AND p.IsActive = 'Y'
ORDER BY p.Name, po.PricePO ASC;

Price Trend Tracking

While PriceLastPO and PriceLastInv give you only the most recent price, you can track price history through the actual order and invoice lines:

-- Price History from Purchase Orders for a Specific Product
SELECT
    bp.Name AS VendorName,
    o.DocumentNo AS PO_Number,
    o.DateOrdered,
    ol.PriceActual AS UnitPrice,
    ol.QtyOrdered,
    (ol.PriceActual * ol.QtyOrdered) AS LineTotal,
    o.C_Currency_ID,
    cur.ISO_Code AS Currency
FROM C_OrderLine ol
    INNER JOIN C_Order o ON ol.C_Order_ID = o.C_Order_ID
    INNER JOIN C_BPartner bp ON o.C_BPartner_ID = bp.C_BPartner_ID
    INNER JOIN C_Currency cur ON o.C_Currency_ID = cur.C_Currency_ID
WHERE ol.M_Product_ID = ?
    AND o.IsSOTrx = 'N'
    AND o.DocStatus IN ('CO', 'CL')
ORDER BY o.DateOrdered DESC;

Price Competitiveness Index

To score vendors on price, calculate how their price compares to the best available price for each product:

-- Price Competitiveness Index
-- 100 = cheapest vendor, lower scores = more expensive
WITH BestPrices AS (
    SELECT
        M_Product_ID,
        MIN(PricePO) AS BestPrice
    FROM M_Product_PO
    WHERE IsActive = 'Y'
        AND Discontinued = 'N'
        AND PricePO > 0
    GROUP BY M_Product_ID
)
SELECT
    bp.Name AS VendorName,
    p.Name AS ProductName,
    po.PricePO AS VendorPrice,
    best.BestPrice,
    CASE
        WHEN po.PricePO = 0 THEN 0
        ELSE ROUND((best.BestPrice / po.PricePO) * 100, 2)
    END AS PriceCompetitivenessIndex
FROM M_Product_PO po
    INNER JOIN M_Product p ON po.M_Product_ID = p.M_Product_ID
    INNER JOIN C_BPartner bp ON po.C_BPartner_ID = bp.C_BPartner_ID
    INNER JOIN BestPrices best ON po.M_Product_ID = best.M_Product_ID
WHERE po.IsActive = 'Y'
    AND po.PricePO > 0
ORDER BY bp.Name, PriceCompetitivenessIndex DESC;

Total Cost of Ownership (TCO)

The cheapest unit price is not always the cheapest total cost. TCO incorporates delivery costs (shipping, handling via CostPerOrder), quality costs (defect rates), and royalty amounts. The following conceptual formula captures this:

-- Total Cost of Ownership Score
-- Combines price, order costs, quality impact, and delivery reliability
SELECT
    bp.Name AS VendorName,
    p.Name AS ProductName,
    po.PricePO AS BasePrice,
    po.CostPerOrder,
    po.RoyaltyAmt,
    po.Order_Min,
    -- Amortized order cost
    CASE WHEN po.Order_Min > 0
        THEN ROUND(po.CostPerOrder / po.Order_Min, 4)
        ELSE 0
    END AS AmortizedOrderCost,
    -- Quality cost estimate: defect rate * unit price (cost of replacing defects)
    COALESCE(qual.DefectRate, 0) AS DefectRate,
    ROUND(po.PricePO * COALESCE(qual.DefectRate, 0) / 100, 4) AS QualityCostPerUnit,
    -- Total cost per unit
    po.PricePO
        + po.RoyaltyAmt
        + CASE WHEN po.Order_Min > 0 THEN po.CostPerOrder / po.Order_Min ELSE 0 END
        + (po.PricePO * COALESCE(qual.DefectRate, 0) / 100)
    AS TCO_PerUnit
FROM M_Product_PO po
    INNER JOIN M_Product p ON po.M_Product_ID = p.M_Product_ID
    INNER JOIN C_BPartner bp ON po.C_BPartner_ID = bp.C_BPartner_ID
    LEFT JOIN (
        -- Subquery: defect rate per vendor
        SELECT
            io.C_BPartner_ID,
            CASE WHEN SUM(CASE WHEN io.MovementType='V+' THEN iol.MovementQty ELSE 0 END) = 0 THEN 0
                ELSE ROUND(
                    SUM(CASE WHEN io.MovementType='V-' THEN ABS(iol.MovementQty) ELSE 0 END) * 100.0
                    / SUM(CASE WHEN io.MovementType='V+' THEN iol.MovementQty ELSE 0 END), 2)
            END AS DefectRate
        FROM M_InOut io
            INNER JOIN M_InOutLine iol ON io.M_InOut_ID = iol.M_InOut_ID
        WHERE io.MovementType IN ('V+', 'V-')
            AND io.DocStatus IN ('CO', 'CL')
        GROUP BY io.C_BPartner_ID
    ) qual ON bp.C_BPartner_ID = qual.C_BPartner_ID
WHERE po.IsActive = 'Y'
    AND po.PricePO > 0
ORDER BY TCO_PerUnit ASC;

6. Building a Vendor Scorecard Process

Now that we understand the data model, let us build a comprehensive vendor scorecard process using iDempiere’s SvrProcess framework. This is the standard way to implement batch processes that can be triggered from the menu, toolbar, or scheduler.

The SvrProcess Framework

Every custom process in iDempiere extends SvrProcess, which is an abstract class that implements the ProcessCall interface. It defines two abstract methods that subclasses must implement:

// From SvrProcess.java
public abstract class SvrProcess implements ProcessCall
{
    /**
     * Prepare process - e.g., get Parameters.
     * Called before doIt().
     */
    abstract protected void prepare();

    /**
     * Process implementation - execute process actions.
     * @return Message (variables are parsed)
     * @throws Exception if not successful
     */
    abstract protected String doIt() throws Exception;
}

The execution flow in the process() method is:

// From SvrProcess.java — process() method (simplified)
private boolean process() {
    String msg = null;
    boolean success = true;
    try {
        autoFillParameters();  // Auto-fill @Parameter annotated fields
        prepare();             // Your parameter setup

        // Fire BEFORE_PROCESS event
        Event eventBP = sendProcessEvent(IEventTopics.BEFORE_PROCESS);
        // Check for errors from event listeners...

        msg = doIt();          // Your business logic

        // Fire AFTER_PROCESS event if successful
        if (msg != null && !msg.startsWith("@Error@")) {
            Event eventAP = sendProcessEvent(IEventTopics.AFTER_PROCESS);
            // Check for errors from event listeners...
        }
    } catch (Throwable e) {
        msg = e.getLocalizedMessage();
        success = false;
    }

    if (msg != null && msg.startsWith("@Error@"))
        success = false;

    m_pi.setSummary(msg, !success);
    return success;
}

Parameter Handling Pattern

The prepare() method reads process parameters using ProcessInfoParameter. The canonical pattern from the SvrProcess javadoc is:

// From SvrProcess.java — prepare() documentation example
ProcessInfoParameter[] para = getParameter();
for (int i = 0; i < para.length; i++)
{
    String name = para[i].getParameterName();
    if (para[i].getParameter() == null)
        ;
    else if (name.equals("A_Asset_Group_ID"))
        p_A_Asset_Group_ID = para[i].getParameterAsInt();
    else if (name.equals("GuaranteeDate"))
        p_GuaranteeDate = (Timestamp)para[i].getParameter();
    else if (name.equals("AttachAsset"))
        p_AttachAsset = "Y".equals(para[i].getParameter());
    else
        log.log(Level.SEVERE, "Unknown Parameter: " + name);
}

Key methods on ProcessInfoParameter:

Method Return Type Usage
getParameterName() String The parameter column name
getParameter() Object The raw parameter value
getParameterAsInt() int Parameter as integer
getParameterAsTimestamp() Timestamp Parameter as date/time

Complete Vendor Scorecard Process

Here is a full implementation of a vendor scorecard process. It calculates a weighted composite score based on delivery, quality, and pricing dimensions:

package org.idempiere.process;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.util.logging.Level;

import org.compiere.model.MProductPO;
import org.compiere.model.I_M_Product_PO;
import org.compiere.model.Query;
import org.compiere.process.ProcessInfoParameter;
import org.compiere.process.SvrProcess;
import org.compiere.util.DB;
import org.compiere.util.Env;

/**
 * Vendor Scorecard Calculation Process.
 *
 * Calculates a weighted composite score (0-100) for each vendor
 * based on delivery performance, quality metrics, and pricing analysis.
 * Updates QualityRating on M_Product_PO with the result.
 */
public class VendorScorecardProcess extends SvrProcess {

    // Process Parameters
    private int p_C_BPartner_ID = 0;         // Optional: specific vendor
    private int p_M_Product_Category_ID = 0; // Optional: product category filter
    private Timestamp p_DateFrom = null;      // Evaluation period start
    private Timestamp p_DateTo = null;        // Evaluation period end

    // Scoring Weights (must sum to 100)
    private static final BigDecimal WEIGHT_DELIVERY = new BigDecimal("40");
    private static final BigDecimal WEIGHT_QUALITY  = new BigDecimal("35");
    private static final BigDecimal WEIGHT_PRICE    = new BigDecimal("25");
    private static final BigDecimal HUNDRED = new BigDecimal("100");

    @Override
    protected void prepare() {
        ProcessInfoParameter[] para = getParameter();
        for (int i = 0; i < para.length; i++) {
            String name = para[i].getParameterName();
            if (para[i].getParameter() == null)
                ;
            else if (name.equals("C_BPartner_ID"))
                p_C_BPartner_ID = para[i].getParameterAsInt();
            else if (name.equals("M_Product_Category_ID"))
                p_M_Product_Category_ID = para[i].getParameterAsInt();
            else if (name.equals("DateFrom"))
                p_DateFrom = para[i].getParameterAsTimestamp();
            else if (name.equals("DateTo"))
                p_DateTo = para[i].getParameterAsTimestamp();
            else
                log.log(Level.SEVERE, "Unknown Parameter: " + name);
        }
    }

    @Override
    protected String doIt() throws Exception {
        log.info("VendorScorecard - C_BPartner_ID=" + p_C_BPartner_ID
            + ", Category=" + p_M_Product_Category_ID
            + ", From=" + p_DateFrom + ", To=" + p_DateTo);

        int vendorsProcessed = 0;
        int recordsUpdated = 0;

        // Build vendor query: either a specific vendor or all active vendors
        String vendorSQL = "SELECT DISTINCT po.C_BPartner_ID, po.M_Product_ID "
            + "FROM M_Product_PO po "
            + "INNER JOIN M_Product p ON po.M_Product_ID = p.M_Product_ID "
            + "WHERE po.IsActive='Y' AND po.Discontinued='N' ";
        if (p_C_BPartner_ID > 0)
            vendorSQL += "AND po.C_BPartner_ID=" + p_C_BPartner_ID + " ";
        if (p_M_Product_Category_ID > 0)
            vendorSQL += "AND p.M_Product_Category_ID="
                + p_M_Product_Category_ID + " ";
        vendorSQL += "ORDER BY po.C_BPartner_ID, po.M_Product_ID";

        PreparedStatement pstmt = null;
        ResultSet rs = null;
        try {
            pstmt = DB.prepareStatement(vendorSQL, get_TrxName());
            rs = pstmt.executeQuery();
            while (rs.next()) {
                int bpartnerID = rs.getInt("C_BPartner_ID");
                int productID = rs.getInt("M_Product_ID");

                BigDecimal deliveryScore = calculateDeliveryScore(
                    bpartnerID, productID);
                BigDecimal qualityScore = calculateQualityScore(
                    bpartnerID);
                BigDecimal priceScore = calculatePriceScore(
                    bpartnerID, productID);

                // Weighted composite: (D*Wd + Q*Wq + P*Wp) / 100
                BigDecimal compositeScore = deliveryScore
                    .multiply(WEIGHT_DELIVERY)
                    .add(qualityScore.multiply(WEIGHT_QUALITY))
                    .add(priceScore.multiply(WEIGHT_PRICE))
                    .divide(HUNDRED, 0, RoundingMode.HALF_UP);

                // Clamp to 0-100
                int finalScore = compositeScore
                    .max(BigDecimal.ZERO)
                    .min(HUNDRED)
                    .intValue();

                // Update M_Product_PO
                updateProductPOScore(bpartnerID, productID, finalScore);
                recordsUpdated++;

                addBufferLog(0, null, new BigDecimal(finalScore),
                    "Vendor=" + bpartnerID
                    + ", Product=" + productID
                    + ", Score=" + finalScore,
                    I_M_Product_PO.Table_ID, 0);
            }
        } finally {
            DB.close(rs, pstmt);
        }

        return "@Updated@ " + recordsUpdated + " vendor-product scores";
    }

    /**
     * Calculate delivery score (0-100) based on on-time delivery rate.
     */
    private BigDecimal calculateDeliveryScore(int C_BPartner_ID,
            int M_Product_ID) {
        String sql = "SELECT "
            + "COUNT(*) AS TotalDeliveries, "
            + "SUM(CASE WHEN io.MovementDate <= o.DatePromised "
            + "  THEN 1 ELSE 0 END) AS OnTime "
            + "FROM M_InOut io "
            + "INNER JOIN C_Order o ON io.C_Order_ID = o.C_Order_ID "
            + "INNER JOIN M_InOutLine iol ON io.M_InOut_ID = iol.M_InOut_ID "
            + "WHERE io.C_BPartner_ID=? "
            + "AND iol.M_Product_ID=? "
            + "AND io.MovementType='V+' "
            + "AND io.DocStatus IN ('CO','CL') "
            + "AND o.DatePromised IS NOT NULL";
        if (p_DateFrom != null)
            sql += " AND io.MovementDate >= " + DB.TO_DATE(p_DateFrom);
        if (p_DateTo != null)
            sql += " AND io.MovementDate <= " + DB.TO_DATE(p_DateTo);

        PreparedStatement pstmt = null;
        ResultSet rs = null;
        try {
            pstmt = DB.prepareStatement(sql, get_TrxName());
            pstmt.setInt(1, C_BPartner_ID);
            pstmt.setInt(2, M_Product_ID);
            rs = pstmt.executeQuery();
            if (rs.next()) {
                int total = rs.getInt("TotalDeliveries");
                int onTime = rs.getInt("OnTime");
                if (total == 0)
                    return new BigDecimal("50"); // No history: neutral score
                return new BigDecimal(onTime)
                    .multiply(HUNDRED)
                    .divide(new BigDecimal(total), 2, RoundingMode.HALF_UP);
            }
        } catch (Exception e) {
            log.log(Level.SEVERE, sql, e);
        } finally {
            DB.close(rs, pstmt);
        }
        return new BigDecimal("50"); // Default neutral score
    }

    /**
     * Calculate quality score (0-100) based on return rate.
     * Quality score = 100 - defect percentage.
     */
    private BigDecimal calculateQualityScore(int C_BPartner_ID) {
        String sql = "SELECT "
            + "COALESCE(SUM(CASE WHEN io.MovementType='V+' "
            + "  THEN iol.MovementQty ELSE 0 END), 0) AS Received, "
            + "COALESCE(SUM(CASE WHEN io.MovementType='V-' "
            + "  THEN ABS(iol.MovementQty) ELSE 0 END), 0) AS Returned "
            + "FROM M_InOut io "
            + "INNER JOIN M_InOutLine iol ON io.M_InOut_ID = iol.M_InOut_ID "
            + "WHERE io.C_BPartner_ID=? "
            + "AND io.MovementType IN ('V+','V-') "
            + "AND io.DocStatus IN ('CO','CL')";
        if (p_DateFrom != null)
            sql += " AND io.MovementDate >= " + DB.TO_DATE(p_DateFrom);
        if (p_DateTo != null)
            sql += " AND io.MovementDate <= " + DB.TO_DATE(p_DateTo);

        PreparedStatement pstmt = null;
        ResultSet rs = null;
        try {
            pstmt = DB.prepareStatement(sql, get_TrxName());
            pstmt.setInt(1, C_BPartner_ID);
            rs = pstmt.executeQuery();
            if (rs.next()) {
                BigDecimal received = rs.getBigDecimal("Received");
                BigDecimal returned = rs.getBigDecimal("Returned");
                if (received.signum() == 0)
                    return new BigDecimal("50"); // No history
                BigDecimal defectRate = returned.multiply(HUNDRED)
                    .divide(received, 2, RoundingMode.HALF_UP);
                return HUNDRED.subtract(defectRate)
                    .max(BigDecimal.ZERO);
            }
        } catch (Exception e) {
            log.log(Level.SEVERE, sql, e);
        } finally {
            DB.close(rs, pstmt);
        }
        return new BigDecimal("50");
    }

    /**
     * Calculate price score (0-100) based on competitiveness.
     * Best price = 100, worst price scales down proportionally.
     */
    private BigDecimal calculatePriceScore(int C_BPartner_ID,
            int M_Product_ID) {
        // Get this vendor's price
        String priceSQL = "SELECT PricePO FROM M_Product_PO "
            + "WHERE C_BPartner_ID=? AND M_Product_ID=? AND IsActive='Y'";
        BigDecimal vendorPrice = DB.getSQLValueBD(get_TrxName(),
            priceSQL, C_BPartner_ID, M_Product_ID);
        if (vendorPrice == null || vendorPrice.signum() == 0)
            return new BigDecimal("50"); // No price data

        // Get best price for this product across all vendors
        String bestSQL = "SELECT MIN(PricePO) FROM M_Product_PO "
            + "WHERE M_Product_ID=? AND IsActive='Y' "
            + "AND Discontinued='N' AND PricePO > 0";
        BigDecimal bestPrice = DB.getSQLValueBD(get_TrxName(),
            bestSQL, M_Product_ID);
        if (bestPrice == null || bestPrice.signum() == 0)
            return new BigDecimal("50");

        // Score: (bestPrice / vendorPrice) * 100
        return bestPrice.multiply(HUNDRED)
            .divide(vendorPrice, 2, RoundingMode.HALF_UP)
            .min(HUNDRED);
    }

    /**
     * Update M_Product_PO.QualityRating with the composite score.
     */
    private void updateProductPOScore(int C_BPartner_ID,
            int M_Product_ID, int score) {
        final String whereClause = "C_BPartner_ID=? AND M_Product_ID=?";
        MProductPO productPO = new Query(getCtx(),
                I_M_Product_PO.Table_Name, whereClause, get_TrxName())
            .setParameters(C_BPartner_ID, M_Product_ID)
            .setOnlyActiveRecords(true)
            .firstOnly();
        if (productPO != null) {
            productPO.setQualityRating(score);
            productPO.saveEx();
        }
    }
}

Process Parameter Design

The process above accepts four parameters. Here is how they would be registered in iDempiere’s Application Dictionary:

Parameter Name DB Column Name Reference Type Mandatory Purpose
Business Partner C_BPartner_ID Table Direct No Filter to a specific vendor (leave blank for all)
Product Category M_Product_Category_ID Table Direct No Filter to a product category
Date From DateFrom Date Yes Start of evaluation period
Date To DateTo Date Yes End of evaluation period

Handling Edge Cases

The process implementation above handles several important edge cases:

  • No transaction history: When a vendor has no receipts, returns, or price data for a product, the score defaults to 50 (neutral). This avoids penalizing new vendors who have not yet had a chance to prove themselves, while also not giving them an unearned high score.
  • Division by zero: All division operations check for zero denominators before dividing. The BigDecimal.divide() method is always called with an explicit RoundingMode to avoid ArithmeticException.
  • Score clamping: The final composite score is clamped to the range 0-100 using BigDecimal.max(ZERO).min(HUNDRED). This prevents nonsensical scores from rounding errors or extreme data.
  • Null prices: The price score calculation checks for null and zero prices before attempting comparison. DB.getSQLValueBD() can return null for empty result sets.

7. ModelValidator Approach for Real-Time Updates

While the SvrProcess scorecard runs on demand (or on a schedule), a ModelValidator can update vendor metrics in real time as documents are completed. This is event-driven programming within iDempiere: your code runs automatically when specific document actions occur.

The ModelValidator Interface

The ModelValidator interface (in org.compiere.model) defines the contract for event-driven model validation. Here are its key methods and constants, taken directly from the source:

// From ModelValidator.java
public interface ModelValidator
{
    // Model change types (for table-level events)
    public static final int TYPE_BEFORE_NEW = 1;
    public static final int TYPE_AFTER_NEW = 4;
    public static final int TYPE_BEFORE_CHANGE = 2;
    public static final int TYPE_AFTER_CHANGE = 5;
    public static final int TYPE_BEFORE_DELETE = 3;
    public static final int TYPE_AFTER_DELETE = 6;

    // Document timing constants (for document action events)
    public static final int TIMING_BEFORE_PREPARE = 1;
    public static final int TIMING_BEFORE_VOID = 2;
    public static final int TIMING_BEFORE_CLOSE = 3;
    public static final int TIMING_BEFORE_REACTIVATE = 4;
    public static final int TIMING_BEFORE_REVERSECORRECT = 5;
    public static final int TIMING_BEFORE_REVERSEACCRUAL = 6;
    public static final int TIMING_BEFORE_COMPLETE = 7;
    public static final int TIMING_AFTER_PREPARE = 8;
    public static final int TIMING_AFTER_COMPLETE = 9;
    public static final int TIMING_AFTER_VOID = 10;
    public static final int TIMING_AFTER_CLOSE = 11;
    public static final int TIMING_AFTER_REACTIVATE = 12;
    public static final int TIMING_AFTER_REVERSECORRECT = 13;
    public static final int TIMING_AFTER_REVERSEACCRUAL = 14;
    public static final int TIMING_BEFORE_POST = 15;
    public static final int TIMING_AFTER_POST = 16;

    /** Initialize validator. */
    public void initialize(ModelValidationEngine engine, MClient client);

    /** Get Client to be monitored. */
    public int getAD_Client_ID();

    /** User logged in. */
    public String login(int AD_Org_ID, int AD_Role_ID, int AD_User_ID);

    /** Model change event of a Table. */
    public String modelChange(PO po, int type) throws Exception;

    /** Validate Document Action. */
    public String docValidate(PO po, int timing);
}

Registration Pattern

In the initialize() method, you register which tables and documents your validator is interested in:

@Override
public void initialize(ModelValidationEngine engine, MClient client) {
    if (client != null)
        m_AD_Client_ID = client.getAD_Client_ID();

    // Register for document events on M_InOut (Material Receipt/Shipment)
    engine.addDocValidate(MInOut.Table_Name, this);

    // Optionally register for table-level events on M_Product_PO
    engine.addModelChange(I_M_Product_PO.Table_Name, this);
}

Complete ModelValidator Implementation

The following validator automatically updates vendor delivery time and quality metrics whenever a vendor receipt (V+) or vendor return (V-) is completed:

package org.idempiere.model;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.Timestamp;
import java.util.logging.Level;

import org.compiere.model.MBPartner;
import org.compiere.model.MClient;
import org.compiere.model.MInOut;
import org.compiere.model.MInOutLine;
import org.compiere.model.MOrder;
import org.compiere.model.MProductPO;
import org.compiere.model.ModelValidationEngine;
import org.compiere.model.ModelValidator;
import org.compiere.model.I_M_Product_PO;
import org.compiere.model.PO;
import org.compiere.model.Query;
import org.compiere.util.CLogger;
import org.compiere.util.DB;
import org.compiere.util.TimeUtil;

/**
 * Model Validator for automatic vendor evaluation updates.
 *
 * Triggers:
 * - TIMING_AFTER_COMPLETE on M_InOut (V+ receipts):
 *   Updates DeliveryTime_Actual on M_Product_PO.
 * - TIMING_AFTER_COMPLETE on M_InOut (V- returns):
 *   Updates QualityRating on M_Product_PO.
 */
public class VendorEvaluationValidator implements ModelValidator {

    private static final CLogger log = CLogger.getCLogger(
        VendorEvaluationValidator.class);
    private int m_AD_Client_ID = -1;

    @Override
    public void initialize(ModelValidationEngine engine, MClient client) {
        if (client != null)
            m_AD_Client_ID = client.getAD_Client_ID();

        // Register for document events on Material Receipt/Shipment
        engine.addDocValidate(MInOut.Table_Name, this);

        log.info("VendorEvaluationValidator initialized"
            + (client != null ? " for client " + client.getName() : ""));
    }

    @Override
    public int getAD_Client_ID() {
        return m_AD_Client_ID;
    }

    @Override
    public String login(int AD_Org_ID, int AD_Role_ID, int AD_User_ID) {
        return null; // No login validation needed
    }

    @Override
    public String modelChange(PO po, int type) throws Exception {
        return null; // No table-level validation needed
    }

    @Override
    public String docValidate(PO po, int timing) {
        if (timing != TIMING_AFTER_COMPLETE)
            return null;

        if (po instanceof MInOut) {
            MInOut inout = (MInOut) po;
            String movementType = inout.getMovementType();

            if (MInOut.MOVEMENTTYPE_VendorReceipts.equals(movementType)) {
                // V+ : Vendor Receipt - update delivery times
                return processVendorReceipt(inout);
            }
            else if (MInOut.MOVEMENTTYPE_VendorReturns.equals(movementType)) {
                // V- : Vendor Return - update quality ratings
                return processVendorReturn(inout);
            }
        }
        return null;
    }

    /**
     * Process a completed Vendor Receipt (V+).
     * Calculates actual delivery time and updates M_Product_PO.
     */
    private String processVendorReceipt(MInOut receipt) {
        int C_Order_ID = receipt.getC_Order_ID();
        if (C_Order_ID <= 0)
            return null; // No linked PO, cannot calculate

        MOrder order = new MOrder(receipt.getCtx(), C_Order_ID,
            receipt.get_TrxName());
        Timestamp dateOrdered = order.getDateOrdered();
        Timestamp dateReceived = receipt.getMovementDate();

        if (dateOrdered == null || dateReceived == null)
            return null;

        // Calculate actual delivery time in days
        long diffMs = dateReceived.getTime() - dateOrdered.getTime();
        int actualDays = (int) (diffMs / (1000 * 60 * 60 * 24));
        if (actualDays < 0) actualDays = 0;

        int C_BPartner_ID = receipt.getC_BPartner_ID();

        // Update DeliveryTime_Actual for each product in this receipt
        MInOutLine[] lines = receipt.getLines(false);
        for (MInOutLine line : lines) {
            if (line.getM_Product_ID() <= 0)
                continue;

            final String whereClause =
                "C_BPartner_ID=? AND M_Product_ID=?";
            MProductPO productPO = new Query(receipt.getCtx(),
                    I_M_Product_PO.Table_Name, whereClause,
                    receipt.get_TrxName())
                .setParameters(C_BPartner_ID, line.getM_Product_ID())
                .setOnlyActiveRecords(true)
                .firstOnly();

            if (productPO != null) {
                productPO.setDeliveryTime_Actual(actualDays);
                productPO.saveEx();
                if (log.isLoggable(Level.FINE))
                    log.fine("Updated DeliveryTime_Actual="
                        + actualDays + " for vendor="
                        + C_BPartner_ID + ", product="
                        + line.getM_Product_ID());
            }
        }
        return null; // null = success
    }

    /**
     * Process a completed Vendor Return (V-).
     * Recalculates quality rating for the vendor on affected products.
     */
    private String processVendorReturn(MInOut vendorReturn) {
        int C_BPartner_ID = vendorReturn.getC_BPartner_ID();

        MInOutLine[] lines = vendorReturn.getLines(false);
        for (MInOutLine line : lines) {
            if (line.getM_Product_ID() <= 0)
                continue;

            // Calculate defect rate for this vendor-product combination
            String sql = "SELECT "
                + "COALESCE(SUM(CASE WHEN io.MovementType='V+' "
                + "  THEN iol.MovementQty ELSE 0 END), 0), "
                + "COALESCE(SUM(CASE WHEN io.MovementType='V-' "
                + "  THEN ABS(iol.MovementQty) ELSE 0 END), 0) "
                + "FROM M_InOut io "
                + "INNER JOIN M_InOutLine iol "
                + "  ON io.M_InOut_ID = iol.M_InOut_ID "
                + "WHERE io.C_BPartner_ID=? "
                + "AND iol.M_Product_ID=? "
                + "AND io.MovementType IN ('V+','V-') "
                + "AND io.DocStatus IN ('CO','CL')";

            BigDecimal received = BigDecimal.ZERO;
            BigDecimal returned = BigDecimal.ZERO;

            java.sql.PreparedStatement pstmt = null;
            java.sql.ResultSet rs = null;
            try {
                pstmt = DB.prepareStatement(sql,
                    vendorReturn.get_TrxName());
                pstmt.setInt(1, C_BPartner_ID);
                pstmt.setInt(2, line.getM_Product_ID());
                rs = pstmt.executeQuery();
                if (rs.next()) {
                    received = rs.getBigDecimal(1);
                    returned = rs.getBigDecimal(2);
                }
            } catch (Exception e) {
                log.log(Level.SEVERE, sql, e);
            } finally {
                DB.close(rs, pstmt);
            }

            // Calculate quality score: 100 - (returned/received * 100)
            int qualityScore = 100;
            if (received.signum() > 0) {
                BigDecimal defectPct = returned
                    .multiply(new BigDecimal("100"))
                    .divide(received, 2, RoundingMode.HALF_UP);
                qualityScore = new BigDecimal("100")
                    .subtract(defectPct)
                    .max(BigDecimal.ZERO)
                    .intValue();
            }

            // Update M_Product_PO.QualityRating
            final String whereClause =
                "C_BPartner_ID=? AND M_Product_ID=?";
            MProductPO productPO = new Query(vendorReturn.getCtx(),
                    I_M_Product_PO.Table_Name, whereClause,
                    vendorReturn.get_TrxName())
                .setParameters(C_BPartner_ID, line.getM_Product_ID())
                .setOnlyActiveRecords(true)
                .firstOnly();

            if (productPO != null) {
                productPO.setQualityRating(qualityScore);
                productPO.saveEx();
                if (log.isLoggable(Level.FINE))
                    log.fine("Updated QualityRating="
                        + qualityScore + " for vendor="
                        + C_BPartner_ID + ", product="
                        + line.getM_Product_ID());
            }
        }
        return null; // null = success
    }
}

Registering the ModelValidator

In iDempiere, you register a model validator through the Application Dictionary. Navigate to System Admin > General Rules > Model Validator and create a new record:

Field Value
Name Vendor Evaluation Validator
Model Validation Class org.idempiere.model.VendorEvaluationValidator
Entity Type User maintained (or your custom entity type)
Description Automatically updates vendor delivery times and quality ratings on document completion

Alternatively, in an OSGi plugin, you can register the validator through a component factory. The key point is that initialize() is called once when the system starts or the plugin activates, and then docValidate() is called every time a registered document transitions through a document action.

TIMING_AFTER_COMPLETE vs. TIMING_BEFORE_COMPLETE

We use TIMING_AFTER_COMPLETE (value 9) rather than TIMING_BEFORE_COMPLETE (value 7) because:

  • TIMING_BEFORE_COMPLETE fires before the document is fully completed. The inventory transactions, matching records, and accounting entries have not yet been created. If your validator returns an error string here, the completion is blocked.
  • TIMING_AFTER_COMPLETE fires after the document is successfully completed. All inventory, matching, and accounting entries are done. This is the right time to read the final state and update evaluation metrics. If you return an error string here, the document is marked as Invalid, but the completion transactions have already been processed.

For vendor evaluation, we want to read the final committed state of the receipt, so TIMING_AFTER_COMPLETE is the correct choice. We also return null (not an error string) because we do not want to block document completion if the evaluation update fails.

8. Reporting and Visualization

Once vendor scores are calculated and stored, you need reporting views to make the data accessible to procurement teams. iDempiere supports SQL views that can be exposed as report views or smart browse sources.

Vendor Dashboard SQL View

Create a comprehensive SQL view that combines all evaluation dimensions:

-- Vendor Evaluation Dashboard View
CREATE OR REPLACE VIEW RV_Vendor_Scorecard AS
SELECT
    bp.AD_Client_ID,
    bp.AD_Org_ID,
    bp.C_BPartner_ID,
    bp.Value AS VendorCode,
    bp.Name AS VendorName,
    bp.IsActive,
    bg.Name AS BPGroupName,
    -- Product-level metrics from M_Product_PO
    po.M_Product_ID,
    p.Name AS ProductName,
    po.QualityRating,
    po.DeliveryTime_Promised,
    po.DeliveryTime_Actual,
    po.PricePO,
    po.PriceList,
    po.PriceLastPO,
    po.PriceLastInv,
    po.CostPerOrder,
    po.RoyaltyAmt,
    po.Order_Min,
    po.Order_Pack,
    po.IsCurrentVendor,
    po.Discontinued,
    po.DiscontinuedAt,
    po.VendorCategory,
    po.VendorProductNo,
    po.Manufacturer,
    -- Delivery variance
    CASE
        WHEN po.DeliveryTime_Promised > 0 AND po.DeliveryTime_Actual > 0
        THEN po.DeliveryTime_Actual - po.DeliveryTime_Promised
        ELSE NULL
    END AS DeliveryVarianceDays,
    -- Price effective status
    CASE
        WHEN po.PriceEffective IS NULL THEN 'No Expiry'
        WHEN po.PriceEffective >= CURRENT_DATE THEN 'Active'
        ELSE 'Expired'
    END AS PriceStatus
FROM M_Product_PO po
    INNER JOIN C_BPartner bp ON po.C_BPartner_ID = bp.C_BPartner_ID
    INNER JOIN M_Product p ON po.M_Product_ID = p.M_Product_ID
    LEFT JOIN C_BP_Group bg ON bp.C_BP_Group_ID = bg.C_BP_Group_ID
WHERE bp.IsVendor = 'Y'
    AND po.IsActive = 'Y';

Key KPI Queries

Here are targeted queries for individual KPIs that can power dashboard widgets or be used in iDempiere’s report framework:

KPI 1: On-Time Delivery Percentage (OTD%)

-- OTD% Summary by Vendor (last 12 months)
SELECT
    bp.C_BPartner_ID,
    bp.Name,
    COUNT(*) AS TotalReceipts,
    SUM(CASE WHEN io.MovementDate <= o.DatePromised THEN 1 ELSE 0 END) AS OnTime,
    ROUND(
        SUM(CASE WHEN io.MovementDate <= o.DatePromised THEN 1 ELSE 0 END)
        * 100.0 / NULLIF(COUNT(*), 0), 1
    ) AS OTD_Pct
FROM M_InOut io
    INNER JOIN C_BPartner bp ON io.C_BPartner_ID = bp.C_BPartner_ID
    INNER JOIN C_Order o ON io.C_Order_ID = o.C_Order_ID
WHERE io.MovementType = 'V+'
    AND io.DocStatus IN ('CO','CL')
    AND o.IsSOTrx = 'N'
    AND o.DatePromised IS NOT NULL
    AND io.MovementDate >= (CURRENT_DATE - INTERVAL '12 months')
GROUP BY bp.C_BPartner_ID, bp.Name
HAVING COUNT(*) >= 3
ORDER BY OTD_Pct DESC;

KPI 2: Quality Rate (Acceptance Rate)

-- Quality Rate by Vendor (last 12 months)
SELECT
    bp.C_BPartner_ID,
    bp.Name,
    SUM(CASE WHEN io.MovementType = 'V+' THEN iol.MovementQty ELSE 0 END) AS QtyReceived,
    SUM(CASE WHEN io.MovementType = 'V-' THEN ABS(iol.MovementQty) ELSE 0 END) AS QtyReturned,
    ROUND(
        (1 - SUM(CASE WHEN io.MovementType = 'V-' THEN ABS(iol.MovementQty) ELSE 0 END)
        / NULLIF(SUM(CASE WHEN io.MovementType = 'V+' THEN iol.MovementQty ELSE 0 END), 0)
        ) * 100, 1
    ) AS QualityRate_Pct
FROM M_InOut io
    INNER JOIN M_InOutLine iol ON io.M_InOut_ID = iol.M_InOut_ID
    INNER JOIN C_BPartner bp ON io.C_BPartner_ID = bp.C_BPartner_ID
WHERE io.MovementType IN ('V+', 'V-')
    AND io.DocStatus IN ('CO','CL')
    AND io.MovementDate >= (CURRENT_DATE - INTERVAL '12 months')
GROUP BY bp.C_BPartner_ID, bp.Name
HAVING SUM(CASE WHEN io.MovementType = 'V+' THEN iol.MovementQty ELSE 0 END) > 0
ORDER BY QualityRate_Pct DESC;

KPI 3: Price Competitiveness Index (weighted average across products)

-- Price Competitiveness Index by Vendor
WITH ProductBestPrices AS (
    SELECT M_Product_ID, MIN(PricePO) AS BestPrice
    FROM M_Product_PO
    WHERE IsActive = 'Y' AND Discontinued = 'N' AND PricePO > 0
    GROUP BY M_Product_ID
)
SELECT
    bp.C_BPartner_ID,
    bp.Name,
    COUNT(*) AS ProductCount,
    ROUND(AVG(
        CASE WHEN po.PricePO > 0
            THEN (best.BestPrice / po.PricePO) * 100
            ELSE NULL
        END
    ), 1) AS AvgPriceCompetitiveness
FROM M_Product_PO po
    INNER JOIN C_BPartner bp ON po.C_BPartner_ID = bp.C_BPartner_ID
    INNER JOIN ProductBestPrices best ON po.M_Product_ID = best.M_Product_ID
WHERE po.IsActive = 'Y'
    AND po.PricePO > 0
GROUP BY bp.C_BPartner_ID, bp.Name
ORDER BY AvgPriceCompetitiveness DESC;

Composite Score Report

This query combines all three KPIs into a single vendor ranking, matching the weighting used in the SvrProcess implementation:

-- Composite Vendor Score Report
WITH DeliveryScores AS (
    SELECT
        io.C_BPartner_ID,
        ROUND(SUM(CASE WHEN io.MovementDate <= o.DatePromised THEN 1 ELSE 0 END)
            * 100.0 / NULLIF(COUNT(*), 0), 2) AS DeliveryScore
    FROM M_InOut io
        INNER JOIN C_Order o ON io.C_Order_ID = o.C_Order_ID
    WHERE io.MovementType = 'V+'
        AND io.DocStatus IN ('CO','CL')
        AND o.DatePromised IS NOT NULL
        AND io.MovementDate >= (CURRENT_DATE - INTERVAL '12 months')
    GROUP BY io.C_BPartner_ID
),
QualityScores AS (
    SELECT
        io.C_BPartner_ID,
        ROUND(
            (1 - SUM(CASE WHEN io.MovementType='V-' THEN ABS(iol.MovementQty) ELSE 0 END)
            / NULLIF(SUM(CASE WHEN io.MovementType='V+' THEN iol.MovementQty ELSE 0 END), 0)
            ) * 100, 2
        ) AS QualityScore
    FROM M_InOut io
        INNER JOIN M_InOutLine iol ON io.M_InOut_ID = iol.M_InOut_ID
    WHERE io.MovementType IN ('V+','V-')
        AND io.DocStatus IN ('CO','CL')
        AND io.MovementDate >= (CURRENT_DATE - INTERVAL '12 months')
    GROUP BY io.C_BPartner_ID
),
PriceScores AS (
    SELECT
        po.C_BPartner_ID,
        ROUND(AVG(
            CASE WHEN po.PricePO > 0
                THEN (best.BestPrice / po.PricePO) * 100
                ELSE NULL END
        ), 2) AS PriceScore
    FROM M_Product_PO po
        INNER JOIN (
            SELECT M_Product_ID, MIN(PricePO) AS BestPrice
            FROM M_Product_PO WHERE IsActive='Y' AND PricePO > 0
            GROUP BY M_Product_ID
        ) best ON po.M_Product_ID = best.M_Product_ID
    WHERE po.IsActive = 'Y' AND po.PricePO > 0
    GROUP BY po.C_BPartner_ID
)
SELECT
    bp.C_BPartner_ID,
    bp.Name AS VendorName,
    COALESCE(d.DeliveryScore, 50) AS DeliveryScore,
    COALESCE(q.QualityScore, 50) AS QualityScore,
    COALESCE(pr.PriceScore, 50) AS PriceScore,
    -- Weighted composite: 40% delivery + 35% quality + 25% price
    ROUND(
        COALESCE(d.DeliveryScore, 50) * 0.40
        + COALESCE(q.QualityScore, 50) * 0.35
        + COALESCE(pr.PriceScore, 50) * 0.25
    , 1) AS CompositeScore
FROM C_BPartner bp
    LEFT JOIN DeliveryScores d ON bp.C_BPartner_ID = d.C_BPartner_ID
    LEFT JOIN QualityScores q ON bp.C_BPartner_ID = q.C_BPartner_ID
    LEFT JOIN PriceScores pr ON bp.C_BPartner_ID = pr.C_BPartner_ID
WHERE bp.IsVendor = 'Y'
    AND bp.IsActive = 'Y'
ORDER BY CompositeScore DESC;

9. Complete Code Examples

This section provides additional self-contained code examples that you can reference during the exam and use as templates for real implementations.

Example 1: Multi-Vendor Comparison Query

Compare all vendors for a specific product, with ranking:

-- Multi-Vendor Comparison for a Specific Product
-- Replace ? with M_Product_ID
SELECT
    ROW_NUMBER() OVER (ORDER BY po.QualityRating DESC, po.PricePO ASC) AS Rank,
    bp.Name AS VendorName,
    po.QualityRating,
    po.PricePO,
    po.PriceLastPO,
    po.PriceLastInv,
    po.DeliveryTime_Promised,
    po.DeliveryTime_Actual,
    po.CostPerOrder,
    po.RoyaltyAmt,
    po.Order_Min,
    po.IsCurrentVendor,
    po.Discontinued,
    po.VendorProductNo,
    po.Manufacturer
FROM M_Product_PO po
    INNER JOIN C_BPartner bp ON po.C_BPartner_ID = bp.C_BPartner_ID
WHERE po.M_Product_ID = ?
    AND po.IsActive = 'Y'
ORDER BY po.QualityRating DESC, po.PricePO ASC;

Example 2: Batch Evaluation Across All Active Vendors

This SQL produces a complete evaluation summary in a single query:

-- Batch Vendor Evaluation Summary
SELECT
    bp.C_BPartner_ID,
    bp.Value AS VendorCode,
    bp.Name AS VendorName,
    -- Product count
    (SELECT COUNT(*) FROM M_Product_PO po
     WHERE po.C_BPartner_ID = bp.C_BPartner_ID
     AND po.IsActive = 'Y') AS ActiveProducts,
    -- Average quality rating from M_Product_PO
    (SELECT ROUND(AVG(po.QualityRating), 1) FROM M_Product_PO po
     WHERE po.C_BPartner_ID = bp.C_BPartner_ID
     AND po.IsActive = 'Y'
     AND po.QualityRating > 0) AS AvgQualityRating,
    -- Receipt count (last 12 months)
    (SELECT COUNT(*) FROM M_InOut io
     WHERE io.C_BPartner_ID = bp.C_BPartner_ID
     AND io.MovementType = 'V+'
     AND io.DocStatus IN ('CO','CL')
     AND io.MovementDate >= CURRENT_DATE - INTERVAL '12 months') AS RecentReceipts,
    -- Return count (last 12 months)
    (SELECT COUNT(*) FROM M_InOut io
     WHERE io.C_BPartner_ID = bp.C_BPartner_ID
     AND io.MovementType = 'V-'
     AND io.DocStatus IN ('CO','CL')
     AND io.MovementDate >= CURRENT_DATE - INTERVAL '12 months') AS RecentReturns,
    -- Average delivery variance
    (SELECT ROUND(AVG(po.DeliveryTime_Actual - po.DeliveryTime_Promised), 1)
     FROM M_Product_PO po
     WHERE po.C_BPartner_ID = bp.C_BPartner_ID
     AND po.IsActive = 'Y'
     AND po.DeliveryTime_Promised > 0
     AND po.DeliveryTime_Actual > 0) AS AvgDeliveryVariance,
    -- Discontinued product count
    (SELECT COUNT(*) FROM M_Product_PO po
     WHERE po.C_BPartner_ID = bp.C_BPartner_ID
     AND po.Discontinued = 'Y') AS DiscontinuedProducts
FROM C_BPartner bp
WHERE bp.IsVendor = 'Y'
    AND bp.IsActive = 'Y'
ORDER BY bp.Name;

Example 3: Delivery Performance with Order Line Detail

This example uses M_MatchPO to link receipt lines to order lines for precise line-level delivery analysis:

-- Line-Level Delivery Performance via M_MatchPO
SELECT
    bp.Name AS VendorName,
    o.DocumentNo AS PO_Number,
    ol.Line AS PO_Line,
    p.Name AS ProductName,
    ol.QtyOrdered,
    iol.MovementQty AS QtyReceived,
    o.DatePromised,
    o.DateOrdered,
    io.MovementDate AS DateReceived,
    (io.MovementDate - o.DateOrdered) AS LeadTimeDays,
    (io.MovementDate - o.DatePromised) AS DaysFromPromised,
    CASE
        WHEN io.MovementDate <= o.DatePromised THEN 'On Time'
        ELSE 'Late'
    END AS DeliveryStatus,
    ol.PriceActual AS OrderPrice,
    io.DocumentNo AS Receipt_Number
FROM M_MatchPO mp
    INNER JOIN C_OrderLine ol ON mp.C_OrderLine_ID = ol.C_OrderLine_ID
    INNER JOIN C_Order o ON ol.C_Order_ID = o.C_Order_ID
    INNER JOIN M_InOutLine iol ON mp.M_InOutLine_ID = iol.M_InOutLine_ID
    INNER JOIN M_InOut io ON iol.M_InOut_ID = io.M_InOut_ID
    INNER JOIN C_BPartner bp ON o.C_BPartner_ID = bp.C_BPartner_ID
    INNER JOIN M_Product p ON ol.M_Product_ID = p.M_Product_ID
WHERE o.IsSOTrx = 'N'
    AND io.MovementType = 'V+'
    AND io.DocStatus IN ('CO','CL')
    AND o.DocStatus IN ('CO','CL')
    AND o.DatePromised IS NOT NULL
ORDER BY bp.Name, o.DateOrdered DESC;

Example 4: Updating DeliveryTime_Actual from Transaction History

A utility method that recalculates and updates DeliveryTime_Actual on M_Product_PO based on the average of all completed receipts:

/**
 * Recalculate DeliveryTime_Actual on M_Product_PO
 * based on the average lead time from completed receipts.
 */
public static void recalculateDeliveryTimes(Properties ctx,
        int C_BPartner_ID, int M_Product_ID, String trxName) {
    // Calculate average lead time from completed receipts
    String sql = "SELECT AVG(io.MovementDate - o.DateOrdered) "
        + "FROM M_InOut io "
        + "INNER JOIN C_Order o ON io.C_Order_ID = o.C_Order_ID "
        + "INNER JOIN M_InOutLine iol ON io.M_InOut_ID = iol.M_InOut_ID "
        + "WHERE io.C_BPartner_ID = ? "
        + "AND iol.M_Product_ID = ? "
        + "AND io.MovementType = 'V+' "
        + "AND io.DocStatus IN ('CO','CL') "
        + "AND o.IsSOTrx = 'N'";

    int avgDays = DB.getSQLValue(trxName, sql,
        C_BPartner_ID, M_Product_ID);

    if (avgDays > 0) {
        final String whereClause = "C_BPartner_ID=? AND M_Product_ID=?";
        MProductPO productPO = new Query(ctx, I_M_Product_PO.Table_Name,
                whereClause, trxName)
            .setParameters(C_BPartner_ID, M_Product_ID)
            .setOnlyActiveRecords(true)
            .firstOnly();

        if (productPO != null) {
            productPO.setDeliveryTime_Actual(avgDays);
            productPO.saveEx();
        }
    }
}

Example 5: Vendor Comparison Report with JSON Output

For integration with external dashboards or APIs, generating structured data from vendor evaluation results:

/**
 * Generate a vendor comparison report for a given product,
 * using MProductPO.getOfProduct() to retrieve all vendor records.
 */
public static String generateVendorReport(Properties ctx,
        int M_Product_ID, String trxName) {
    MProductPO[] vendors = MProductPO.getOfProduct(ctx,
        M_Product_ID, trxName);

    StringBuilder sb = new StringBuilder();
    sb.append("Product Vendor Report (M_Product_ID=")
      .append(M_Product_ID).append(")\n");
    sb.append("Total Vendors: ").append(vendors.length).append("\n\n");

    for (MProductPO vendor : vendors) {
        MBPartner bp = MBPartner.get(ctx, vendor.getC_BPartner_ID());
        sb.append("Vendor: ")
          .append(bp != null ? bp.getName() : "Unknown")
          .append(vendor.isCurrentVendor() ? " [CURRENT]" : "")
          .append("\n");
        sb.append("  Quality Rating:       ")
          .append(vendor.getQualityRating()).append("/100\n");
        sb.append("  PO Price:             ")
          .append(vendor.getPricePO()).append("\n");
        sb.append("  Last PO Price:        ")
          .append(vendor.getPriceLastPO()).append("\n");
        sb.append("  Last Invoice Price:   ")
          .append(vendor.getPriceLastInv()).append("\n");
        sb.append("  Promised Delivery:    ")
          .append(vendor.getDeliveryTime_Promised())
          .append(" days\n");
        sb.append("  Actual Delivery:      ")
          .append(vendor.getDeliveryTime_Actual())
          .append(" days\n");
        sb.append("  Cost Per Order:       ")
          .append(vendor.getCostPerOrder()).append("\n");
        sb.append("  Royalty Amount:       ")
          .append(vendor.getRoyaltyAmt()).append("\n");
        sb.append("  Min Order Qty:        ")
          .append(vendor.getOrder_Min()).append("\n");
        sb.append("  Order Pack Qty:       ")
          .append(vendor.getOrder_Pack()).append("\n");
        sb.append("  Vendor Product No:    ")
          .append(vendor.getVendorProductNo()).append("\n");
        sb.append("  Manufacturer:         ")
          .append(vendor.getManufacturer()).append("\n");
        sb.append("  Discontinued:         ")
          .append(vendor.isDiscontinued()).append("\n");
        if (vendor.getDiscontinuedAt() != null)
            sb.append("  Discontinued At:      ")
              .append(vendor.getDiscontinuedAt()).append("\n");
        sb.append("\n");
    }

    return sb.toString();
}

10. Common Pitfalls and Best Practices

Pitfalls to Avoid

Pitfall Why It Happens How to Avoid
Evaluating vendors without sufficient data New vendors or products may have only one or two transactions Set a minimum threshold (e.g., at least 3 receipts) before assigning a score. Use a neutral default (50) for insufficient data.
Ignoring the IsCurrentVendor constraint The beforeSave() in MProductPO enforces exactly one current vendor per product If your process changes IsCurrentVendor, first unset it on the old vendor, then set it on the new one — in that order.
Comparing prices across different currencies M_Product_PO records can have different C_Currency_ID values Always convert to a common currency using currencyBase() or currencyConvert() before comparing prices.
BigDecimal division without RoundingMode BigDecimal.divide() throws ArithmeticException for non-terminating decimals Always specify a scale and RoundingMode, e.g., .divide(divisor, 2, RoundingMode.HALF_UP).
Division by zero in defect rate calculations A vendor with no V+ receipts but existing V- returns Always check if (received.signum() > 0) before dividing. Use SQL NULLIF() to prevent database-level division errors.
Not filtering by DocStatus Draft, voided, or reversed documents should not count in evaluations Always include AND DocStatus IN ('CO','CL') for completed and closed documents only.
Blocking document completion from a ModelValidator Returning a non-null error string from docValidate() blocks the document action For evaluation updates, always return null from docValidate(). Log errors instead of blocking.
Using PriceLastPO without context PriceLastPO is a single snapshot — it does not capture history For trend analysis, query actual C_OrderLine.PriceActual from historical POs instead.
Not accounting for partial receipts A single PO may be received in multiple shipments Use M_MatchPO for line-level matching rather than header-level C_Order_ID on M_InOut.
Treating DeliveryTime fields as system-maintained DeliveryTime_Promised and DeliveryTime_Actual on M_Product_PO are editable fields, not auto-calculated Build a process or validator to keep them current. They are informational defaults, not transactional measures.

Best Practices

  • Define your evaluation period explicitly. Always parameterize the date range in both processes and queries. A rolling 12-month window is common, but some industries need shorter or longer windows.
  • Use weighted scores that reflect your business priorities. The 40/35/25 split (delivery/quality/price) used in our examples is a starting point. An automotive manufacturer may weight quality at 50%, while a commodity trader may weight price at 50%.
  • Separate the score from the action. Calculate and store scores in QualityRating, but make vendor switching (IsCurrentVendor changes) a separate, human-approved decision. Automatic vendor switching based on scores can create supply chain instability.
  • Track discontinued products. The Discontinued and DiscontinuedAt fields on M_Product_PO are critical risk indicators. A vendor who discontinues products frequently is a supply chain risk. Include discontinuation frequency in your evaluation.
  • Consider total cost of ownership. Unit price alone is misleading. CostPerOrder (fixed ordering cost), RoyaltyAmt, Order_Min (minimum order size), and quality-related costs must be factored in for a true comparison.
  • Schedule the scorecard process regularly. Use iDempiere’s Scheduler to run the VendorScorecardProcess weekly or monthly. This ensures scores stay current even if the ModelValidator is not handling every edge case.
  • Audit and validate scores. Include the addBufferLog() calls in your process so that every score update is traceable in the process log. This provides an audit trail for procurement decisions.

11. Exam Preparation: Key Concepts to Remember

For the certification exam, focus on these core concepts and their exact representations in the iDempiere codebase:

Table and Column Names

Concept Table Key Columns
Vendor-Product relationship M_Product_PO (Table_ID=210) C_BPartner_ID, M_Product_ID, QualityRating, PricePO, DeliveryTime_Promised, DeliveryTime_Actual
Material Receipt M_InOut (Table_ID=319) MovementType, MovementDate, C_BPartner_ID, C_Order_ID, DocStatus
Purchase Order C_Order DatePromised, DateOrdered, IsSOTrx, C_BPartner_ID, DocStatus
PO-Receipt Match M_MatchPO C_OrderLine_ID, M_InOutLine_ID, Qty
Vendor Master C_BPartner IsVendor, C_BP_Group_ID, PO_PriceList_ID, PO_DiscountSchema_ID

Movement Type Constants

Constant Value Meaning
MOVEMENTTYPE_VendorReceipts "V+" Goods received from vendor
MOVEMENTTYPE_VendorReturns "V-" Goods returned to vendor
MOVEMENTTYPE_CustomerShipment "C-" Goods shipped to customer
MOVEMENTTYPE_CustomerReturns "C+" Goods returned from customer

ModelValidator Timing Constants

Constant Value When It Fires
TIMING_BEFORE_COMPLETE 7 Before document completion — can block the action
TIMING_AFTER_COMPLETE 9 After document completion — document is already committed
TIMING_BEFORE_PREPARE 1 Before document preparation
TIMING_AFTER_PREPARE 8 After document preparation

SvrProcess Lifecycle

Step Method Purpose
1 autoFillParameters() Auto-fills @Parameter annotated fields
2 prepare() Read and store process parameters
3 BEFORE_PROCESS event OSGi event for pre-processing hooks
4 doIt() Execute business logic, return message
5 AFTER_PROCESS event OSGi event for post-processing hooks
6 postProcess() Post-commit actions (outside transaction)

Key Method Signatures to Know

// MProductPO — retrieve all vendors for a product
public static MProductPO[] getOfProduct(Properties ctx,
    int M_Product_ID, String trxName)

// MProductPO — beforeSave enforces single IsCurrentVendor per product
protected boolean beforeSave(boolean newRecord)

// X_M_Product_PO — read-only price setters (set_ValueNoCheck)
public void setPriceLastPO(BigDecimal PriceLastPO)
public void setPriceLastInv(BigDecimal PriceLastInv)

// X_M_Product_PO — writable evaluation fields
public void setQualityRating(int QualityRating)
public void setDeliveryTime_Actual(int DeliveryTime_Actual)
public void setDeliveryTime_Promised(int DeliveryTime_Promised)

// MInOut — movement direction check pattern
boolean inTrx = MovementType.charAt(1) == '+';  // V+ = incoming

// ModelValidator — interface methods
public void initialize(ModelValidationEngine engine, MClient client)
public String modelChange(PO po, int type) throws Exception
public String docValidate(PO po, int timing)

// SvrProcess — abstract methods
abstract protected void prepare()
abstract protected String doIt() throws Exception

// ProcessInfoParameter — value retrieval
public String getParameterName()
public int getParameterAsInt()
public Timestamp getParameterAsTimestamp()
public Object getParameter()

12. Summary

This lesson has provided a comprehensive guide to building a vendor evaluation and scoring system in iDempiere. Here are the key takeaways:

  • M_Product_PO is the foundation. The M_Product_PO table (Table_ID=210) stores the vendor-product relationship with evaluation fields including QualityRating (0-100), DeliveryTime_Promised, DeliveryTime_Actual, multiple price fields (PricePO, PriceLastPO, PriceLastInv), CostPerOrder, and RoyaltyAmt.
  • Only one current vendor is allowed per product. The beforeSave() method in MProductPO enforces the constraint that only one active record per product can have IsCurrentVendor='Y'.
  • Delivery performance is measured by comparing M_InOut.MovementDate against C_Order.DatePromised. Material Receipts with MovementType='V+' (the constant MOVEMENTTYPE_VendorReceipts) are the source for actual delivery data.
  • Quality is measured by the V+ to V- ratio. Vendor Receipts (V+) vs. Vendor Returns (V-) from the M_InOut table provide an objective quality defect rate. The MOVEMENTTYPE_VendorReturns constant has the value "V-".
  • PriceLastPO and PriceLastInv are read-only system fields. They use set_ValueNoCheck in the generated model, meaning they are updated automatically by the system when POs and invoices are completed. For historical price trends, query C_OrderLine.PriceActual directly.
  • The SvrProcess framework provides the prepare()/doIt() pattern for batch processes. Parameters are read in prepare() using ProcessInfoParameter.getParameterAsInt(), getParameterAsTimestamp(), and related methods. The doIt() method contains the business logic and returns a result message.
  • ModelValidator enables real-time event-driven updates. By implementing the ModelValidator interface and registering for TIMING_AFTER_COMPLETE (value 9) on M_InOut, you can automatically update delivery times and quality ratings as documents are completed.
  • Always filter by DocStatus IN (‘CO’,’CL’). Only completed and closed documents represent finalized business transactions. Draft, voided, and reversed documents must be excluded from all evaluation calculations.
  • Use weighted composite scoring for overall vendor ranking. A typical weighting of 40% delivery, 35% quality, and 25% price provides a balanced starting point that can be adjusted to specific business requirements.
  • Handle edge cases carefully. Division by zero, null values, insufficient transaction history, and currency differences are all real-world issues that must be handled explicitly in both SQL queries and Java processes.

In the next lesson, we will build on these vendor evaluation techniques to explore automated replenishment and how vendor scores can influence purchase order generation through the Replenishment process.

You Missed