Vendor Evaluation & Scoring
Overview
- What you’ll learn:
- The iDempiere data model for vendor evaluation, centered on the
M_Product_POtable and its fields - How to analyze delivery performance using
M_InOut(Material Receipt) andC_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
SvrProcessframework - Implementing a
ModelValidatorfor real-time, event-driven vendor rating updates - Practical SQL queries and full Java code examples grounded in real iDempiere source code
- The iDempiere data model for vendor evaluation, centered on the
- 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 explicitRoundingModeto avoidArithmeticException. - 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 (IsCurrentVendorchanges) a separate, human-approved decision. Automatic vendor switching based on scores can create supply chain instability. - Track discontinued products. The
DiscontinuedandDiscontinuedAtfields onM_Product_POare 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
VendorScorecardProcessweekly 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_POtable (Table_ID=210) stores the vendor-product relationship with evaluation fields includingQualityRating(0-100),DeliveryTime_Promised,DeliveryTime_Actual, multiple price fields (PricePO,PriceLastPO,PriceLastInv),CostPerOrder, andRoyaltyAmt. - Only one current vendor is allowed per product. The
beforeSave()method inMProductPOenforces the constraint that only one active record per product can haveIsCurrentVendor='Y'. - Delivery performance is measured by comparing M_InOut.MovementDate against C_Order.DatePromised. Material Receipts with
MovementType='V+'(the constantMOVEMENTTYPE_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 theM_InOuttable provide an objective quality defect rate. TheMOVEMENTTYPE_VendorReturnsconstant has the value"V-". - PriceLastPO and PriceLastInv are read-only system fields. They use
set_ValueNoCheckin the generated model, meaning they are updated automatically by the system when POs and invoices are completed. For historical price trends, queryC_OrderLine.PriceActualdirectly. - The SvrProcess framework provides the prepare()/doIt() pattern for batch processes. Parameters are read in
prepare()usingProcessInfoParameter.getParameterAsInt(),getParameterAsTimestamp(), and related methods. ThedoIt()method contains the business logic and returns a result message. - ModelValidator enables real-time event-driven updates. By implementing the
ModelValidatorinterface and registering forTIMING_AFTER_COMPLETE(value 9) onM_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.