Procurement Analytics & Automation
Overview
- What you’ll learn:
- The iDempiere replenishment framework:
MReplenishrules,ReplenishReportprocess, and output modes (PO, Requisition, Movement, Distribution) - The
MStorageOnHandAPI for querying inventory levels across warehouses, locators, and attribute set instances - How to build custom procurement processes using the
SvrProcesspattern withprepare()/doIt()lifecycle - Procurement reporting SQL using actual iDempiere table and column names
- Automation patterns: scheduled processes, ModelValidator triggers, and event-driven procurement workflows
- The iDempiere replenishment framework:
- Prerequisites: Lesson 1 — Procurement Overview & Requisitions, Lesson 2 — Purchase Orders
- Estimated reading time: 25 minutes
1. Introduction: Automating Procurement
Manual procurement — checking stock levels, identifying shortages, creating purchase orders — is time-consuming and error-prone. Missed reorder points lead to stockouts; over-ordering ties up capital in excess inventory. iDempiere provides a comprehensive replenishment framework that automates the entire cycle: monitor inventory levels, calculate required quantities, and generate purchase orders or requisitions automatically.
The procurement automation framework consists of three interconnected layers:
| Layer | Component | Purpose |
|---|---|---|
| Rules | MReplenish (M_Replenish) |
Defines per-product, per-warehouse reorder rules: minimum level, maximum level, replenishment type |
| Inventory | MStorageOnHand (M_StorageOnHand) |
Tracks on-hand quantities by product, locator, and attribute set instance |
| Engine | ReplenishReport |
Process that reads rules, queries inventory, calculates needs, and generates POs/Requisitions/Movements |
2. MReplenish: Replenishment Rules
The MReplenish class (source file: org.compiere.model.MReplenish) extends X_M_Replenish and defines the replenishment policy for each product in each warehouse. Each record in the M_Replenish table represents one rule: “For product X in warehouse Y, use replenishment type Z with these parameters.”
2.1 ReplenishType Constants
The replenishment type determines when and how the system suggests reordering. These constants are defined in X_M_Replenish:
| Constant | Value | Description |
|---|---|---|
REPLENISHTYPE_Manual |
"0" |
No automatic replenishment. Product appears in reports but no calculation is done. |
REPLENISHTYPE_ReorderBelowMinimumLevel |
"1" |
When QtyOnHand + QtyOrdered – QtyReserved falls below Level_Min, reorder up to Level_Max. |
REPLENISHTYPE_MaintainMaximumLevel |
"2" |
Always order to bring stock up to Level_Max, regardless of current level relative to Level_Min. |
REPLENISHTYPE_Custom |
"9" |
Delegates to a custom Java class implementing ReplenishInterface. |
2.2 Key Fields
| Field | Type | Description |
|---|---|---|
M_Product_ID |
int (FK) | The product this rule applies to |
M_Warehouse_ID |
int (FK) | The warehouse this rule applies to |
ReplenishType |
String | Replenishment algorithm (see constants above) |
Level_Min |
BigDecimal | Minimum stock level (reorder point) |
Level_Max |
BigDecimal | Maximum stock level (target quantity) |
M_WarehouseSource_ID |
int (FK) | Source warehouse for inter-warehouse movements (used with MMM output mode) |
M_Locator_ID |
int (FK) | Specific locator for receipt (optional, defaults to warehouse default locator) |
C_BPartner_ID |
int (FK) | Preferred vendor for this product in this warehouse (overrides product-level vendor) |
QtyBatchSize |
BigDecimal | Batch size for production replenishment |
2.3 The getForProduct() Method
The MReplenish class provides a static method to retrieve the replenishment rule for a specific product and warehouse, with an organization-specific override pattern:
public static MReplenish getForProduct(Properties ctx, int M_Product_ID,
int M_Warehouse_ID, String trxName) {
// First try org-specific rule
MReplenish retValue = new Query(ctx, Table_Name,
"M_Product_ID=? AND M_Warehouse_ID=?", trxName)
.setParameters(M_Product_ID, M_Warehouse_ID)
.setOnlyActiveRecords(true)
.first();
return retValue;
}
3. MStorageOnHand: Inventory Queries
The MStorageOnHand class (source file: org.compiere.model.MStorageOnHand) extends X_M_StorageOnHand and manages the on-hand inventory records. Each record in M_StorageOnHand tracks the quantity of a specific product at a specific locator with a specific attribute set instance and material policy date (for FIFO/LIFO).
3.1 The add() Method Chain
The add() method is the primary entry point for updating inventory. It provides three overloads with increasing specificity:
// Overload 1: Full parameters with date material policy
public static BigDecimal add(Properties ctx,
int M_Warehouse_ID, int M_Locator_ID,
int M_Product_ID, int M_AttributeSetInstance_ID,
BigDecimal diffQtyOnHand,
Timestamp dateMPolicy, String trxName) {
// ...
MStorageOnHand storage = getOrCreate(ctx, M_Locator_ID,
M_Product_ID, M_AttributeSetInstance_ID, dateMPolicy, trxName);
BigDecimal oldQty = storage.getQtyOnHand();
BigDecimal newQty = oldQty.add(diffQtyOnHand);
storage.setQtyOnHand(newQty);
storage.checkNegativeOnHand(diffQtyOnHand); // Throws if disallowed
storage.saveEx(trxName);
return newQty;
}
The getOrCreate() pattern is critical: if a matching storage record exists, it is retrieved; otherwise, a new one is created with QtyOnHand = 0. This ensures every inventory transaction has a target record.
3.2 Static Query Methods
MStorageOnHand provides a comprehensive set of static methods for querying inventory levels at different granularities:
| Method | Parameters | Description |
|---|---|---|
getQtyOnHand() |
Product, Warehouse, ASI, trxName | Total on-hand for a product in a warehouse. If ASI=0, returns sum across all ASI. |
getQtyOnHandWithASIZero() |
Product, Warehouse, trxName | On-hand only for ASI=0 records (no attribute tracking). |
getQtyOnHandForReservation() |
Product, Warehouse, ASI, trxName | On-hand filtered by LocatorType.IsAvailableForReservation = 'Y'. Excludes quarantine/damaged locators. |
getQtyOnHandForShipping() |
Product, Warehouse, ASI, trxName | On-hand filtered by LocatorType.IsAvailableForShipping = 'Y'. |
getQtyOnHandForLocator() |
Product, Locator, ASI, trxName | On-hand at a specific locator within a warehouse. |
getQtyOnHandForLocatorWithASIMovementDate() |
Product, Locator, ASI, MovementDate, trxName | Historical on-hand at a point in time, calculated by subtracting transactions after the date. |
getWarehouse() |
Warehouse, Product, ASI, allAttInst, minGuarantee, FiFo, trxName | Returns array of storage records with optional filtering by guarantee date and FIFO/LIFO ordering. |
getWarehouseNegative() |
Warehouse, Product, ASI, minGuarantee, FiFo, trxName | Returns only negative-quantity storage records (for shortage detection). |
3.3 The getWarehouse() Method
The getWarehouse() method is particularly important for allocation and picking processes:
public static MStorageOnHand[] getWarehouse(Properties ctx,
int M_Warehouse_ID, int M_Product_ID, int M_AttributeSetInstance_ID,
boolean allAttributeInstances, Timestamp minGuaranteeDate,
boolean FiFo, String trxName) {
StringBuilder sql = new StringBuilder(
"SELECT s.M_Product_ID, s.M_Locator_ID, s.M_AttributeSetInstance_ID,")
.append(" s.DateMaterialPolicy, SUM(s.QtyOnHand) as QtyOnHand")
.append(" FROM M_StorageOnHand s")
.append(" JOIN M_Locator l ON (s.M_Locator_ID = l.M_Locator_ID)")
.append(" WHERE l.M_Warehouse_ID=?")
.append(" AND s.M_Product_ID=?")
.append(" AND s.QtyOnHand <> 0");
// ... optional ASI filter, guarantee date filter ...
// FIFO vs LIFO ordering
sql.append(" ORDER BY s.DateMaterialPolicy");
if (!FiFo)
sql.append(" DESC");
sql.append(", s.M_AttributeSetInstance_ID");
if (!FiFo)
sql.append(" DESC");
// ... execute and return ...
}
The DateMaterialPolicy column is the key to FIFO/LIFO: it records when each lot entered inventory. FIFO ordering (ASC) picks oldest stock first; LIFO (DESC) picks newest.
3.4 The beforeSave() Negative Check
The MStorageOnHand.beforeSave() method enforces the warehouse-level IsDisallowNegativeInv flag:
@Override
protected boolean beforeSave(boolean newRecord) {
if (newRecord || is_ValueChanged("QtyOnHand")) {
MWarehouse wh = new MWarehouse(getCtx(), getM_Warehouse_ID(),
get_TrxName());
if (wh.isDisallowNegativeInv()) {
// Check total on-hand across all ASI at this locator
String sql = "SELECT SUM(QtyOnHand) FROM M_StorageOnHand s"
+ " INNER JOIN M_Locator l ON (s.M_Locator_ID=l.M_Locator_ID)"
+ " WHERE s.M_Product_ID=?"
+ " AND l.M_Warehouse_ID=?"
+ " AND l.M_Locator_ID=?"
+ " AND s.M_AttributeSetInstance_ID<>?";
BigDecimal QtyOnHand = DB.getSQLValueBDEx(get_TrxName(), sql,
new Object[]{getM_Product_ID(), getM_Warehouse_ID(),
getM_Locator_ID(), getM_AttributeSetInstance_ID()});
if (QtyOnHand == null)
QtyOnHand = Env.ZERO;
QtyOnHand = QtyOnHand.add(getQtyOnHand());
if (getQtyOnHand().compareTo(BigDecimal.ZERO) < 0 ||
QtyOnHand.compareTo(Env.ZERO) < 0) {
log.saveError("Error",
new NegativeInventoryDisallowedException(...));
return false;
}
}
}
return true;
}
This check prevents inventory from going negative at the warehouse level (summing across all ASIs at the same locator). It is a critical safeguard for warehouse management integrity.
4. ReplenishReport: The Replenishment Engine
The ReplenishReport class (source file: org.compiere.process.ReplenishReport) extends SvrProcess and is the main engine that drives automated procurement. It reads replenishment rules, queries current inventory levels, calculates required quantities, and generates output documents.
4.1 Process Parameters
| Parameter | Type | Description |
|---|---|---|
M_Warehouse_ID |
int | Target warehouse for replenishment analysis |
ReplenishmentCreate |
String | Output document type: "POO" (Purchase Order), "POR" (Purchase Requisition), "MMM" (Warehouse Movement), "DOO" (Distribution Order) |
C_DocType_ID |
int | Optional: specific document type for generated documents |
IsKanban |
boolean | Whether to include kanban replenishment items |
4.2 The doIt() Flow
The main process logic follows this sequence:
- fillTable() — Populates the temporary
T_Replenishtable with replenishment data - Calculate quantities — For each product, compute: QtyOnHand + QtyOrdered - QtyReserved vs. Level_Min/Level_Max
- Apply constraints — Enforce Order_Min and Order_Pack from
M_Product_PO - Generate output — Create POs, Requisitions, Movements, or Distribution Orders
4.3 The fillTable() SQL
The core replenishment calculation uses a complex SQL query that joins M_Replenish with inventory data:
// Simplified from ReplenishReport.fillTable():
StringBuilder sql = new StringBuilder(
"INSERT INTO T_Replenish "
+ "(AD_PInstance_ID, M_Warehouse_ID, M_Product_ID, AD_Client_ID, AD_Org_ID, "
+ " ReplenishType, Level_Min, Level_Max, "
+ " C_BPartner_ID, Order_Min, Order_Pack, QtyToOrder, QtyOnHand, "
+ " M_WarehouseSource_ID, DatePromised) "
+ "SELECT ").append(getAD_PInstance_ID())
.append(", r.M_Warehouse_ID, r.M_Product_ID, r.AD_Client_ID, r.AD_Org_ID, ")
.append(" r.ReplenishType, r.Level_Min, r.Level_Max, ")
.append(" po.C_BPartner_ID, po.Order_Min, po.Order_Pack, 0, ")
.append(" bomQtyOnHand(r.M_Product_ID, r.M_Warehouse_ID, 0) as QtyOnHand, ")
.append(" r.M_WarehouseSource_ID, null ")
.append("FROM M_Replenish r ")
.append(" INNER JOIN M_Product_PO po ON (r.M_Product_ID=po.M_Product_ID) ")
.append("WHERE r.ReplenishType <> '0' ") // Exclude Manual
.append(" AND po.IsCurrentVendor='Y' ")
.append(" AND r.IsActive='Y' AND po.IsActive='Y' ")
.append(" AND r.M_Warehouse_ID=?");
);
Key observations:
- Uses
bomQtyOnHand()database function to include BOM component quantities - Joins with
M_Product_POto get the current vendor (IsCurrentVendor = 'Y') - Excludes manual replenishment type (
ReplenishType <> '0') - Picks up
Order_MinandOrder_Packconstraints from the vendor-product record
4.4 Quantity Calculation
After populating the temporary table, the process calculates the required order quantity:
// For REPLENISHTYPE_ReorderBelowMinimumLevel ("1"):
// Available = QtyOnHand + QtyOrdered - QtyReserved
// If Available < Level_Min:
// QtyToOrder = Level_Max - Available
// For REPLENISHTYPE_MaintainMaximumLevel ("2"):
// Available = QtyOnHand + QtyOrdered - QtyReserved
// If Available < Level_Max:
// QtyToOrder = Level_Max - Available
// The key difference: "Reorder Below Min" only triggers when stock falls
// below Level_Min, while "Maintain Max" always orders to reach Level_Max.
4.5 Order_Min and Order_Pack Constraints
After calculating the raw QtyToOrder, the process applies vendor constraints from M_Product_PO:
// Order_Min: enforce minimum order quantity
BigDecimal QtyToOrder = ...; // calculated above
BigDecimal orderMin = replenish.getOrder_Min();
if (orderMin.signum() > 0 && QtyToOrder.compareTo(orderMin) < 0) {
QtyToOrder = orderMin; // Round up to minimum
}
// Order_Pack: round up to next pack multiple
BigDecimal orderPack = replenish.getOrder_Pack();
if (orderPack.signum() > 0) {
// Calculate how many packs are needed
BigDecimal packs = QtyToOrder.divide(orderPack, 0, RoundingMode.CEILING);
QtyToOrder = packs.multiply(orderPack);
}
// Example: If QtyToOrder=73, Order_Pack=25
// packs = ceil(73/25) = 3
// QtyToOrder = 3 * 25 = 75
4.6 Output Generation
Depending on the ReplenishmentCreate parameter, the process generates different document types:
| Mode | DocBaseType | Method | Description |
|---|---|---|---|
"POO" |
POO | createPO() |
Creates Purchase Orders, one per vendor, consolidating all products for that vendor |
"POR" |
POR | createRequisition() |
Creates Purchase Requisitions for approval workflow |
"MMM" |
MMM | createMovement() |
Creates Warehouse Movements from M_WarehouseSource_ID to the target warehouse |
"DOO" |
DOO | createDO() |
Creates Distribution Orders for multi-warehouse distribution |
4.7 Custom Replenishment via ReplenishInterface
When ReplenishType = "9" (Custom), the process delegates to a custom Java class that implements the ReplenishInterface. This allows organizations to implement sophisticated algorithms such as seasonal demand forecasting, economic order quantity (EOQ) calculations, or safety stock formulas:
// Custom replenishment class must implement:
public interface ReplenishInterface {
/**
* Calculate the quantity to order
* @param replenish the replenishment rule
* @param product the product
* @param warehouse the warehouse
* @return quantity to order, or zero if no replenishment needed
*/
public BigDecimal getQtyToOrder(MReplenish replenish,
MProduct product, MWarehouse warehouse);
}
5. ReplenishReportProduction
The ReplenishReportProduction class extends the standard ReplenishReport with manufacturing-specific capabilities. In addition to creating purchase orders and requisitions, it can generate manufacturing orders for products that are produced internally rather than purchased.
Key differences from ReplenishReport:
- Supports BOM (Bill of Materials) explosion for manufactured products
- Can generate manufacturing orders instead of purchase orders
- Uses
QtyBatchSizefromM_Replenishfor production batch sizing - Considers production lead times in addition to purchase lead times
6. SvrProcess: Building Custom Procurement Processes
The SvrProcess class (source file: org.compiere.process.SvrProcess) is the base class for all server-side processes in iDempiere. Custom procurement analytics, batch operations, and reporting processes extend this class.
6.1 The Process Lifecycle
// SvrProcess lifecycle:
// 1. autoFillParameters() - auto-fills annotated fields from AD_PInstance_Para
// 2. prepare() - parse parameters (called by framework)
// 3. doIt() - main business logic (implement this)
// 4. postProcess(success) - cleanup / notification
6.2 Parameter Access Methods
| Method | Return Type | Description |
|---|---|---|
getParameterAsInt(name) |
int | Get integer parameter by name |
getParameterAsString(name) |
String | Get string parameter |
getParameterAsTimestamp(name) |
Timestamp | Get date parameter |
getParameterAsBigDecimal(name) |
BigDecimal | Get numeric parameter |
getParameterAsBoolean(name) |
boolean | Get boolean parameter |
getRecord_ID() |
int | Get the record ID for context (when run from a window/record) |
getTable_ID() |
int | Get the table ID for context |
getAD_PInstance_ID() |
int | Process instance ID for temporary table keying |
6.3 Logging and User Feedback
// Simple log entry
addLog("Processing vendor: " + vendorName);
// Structured log with record reference
addBufferLog(0, null, amount,
"@C_Order_ID@ " + order.getDocumentNo(),
MOrder.Table_ID, order.getC_Order_ID());
// This creates a clickable link in the process result
6.4 Complete Custom Process Example
/**
* Custom procurement analytics process that generates a
* spend analysis report by vendor and product category.
*/
public class ProcurementSpendAnalysis extends SvrProcess {
private int p_C_BPartner_ID = 0;
private Timestamp p_DateFrom = null;
private Timestamp p_DateTo = null;
private int p_M_Product_Category_ID = 0;
@Override
protected void prepare() {
ProcessInfoParameter[] params = getParameter();
for (ProcessInfoParameter para : params) {
String name = para.getParameterName();
if ("C_BPartner_ID".equals(name))
p_C_BPartner_ID = para.getParameterAsInt();
else if ("DateOrdered".equals(name)) {
p_DateFrom = (Timestamp) para.getParameter();
p_DateTo = (Timestamp) para.getParameter_To();
}
else if ("M_Product_Category_ID".equals(name))
p_M_Product_Category_ID = para.getParameterAsInt();
}
}
@Override
protected String doIt() throws Exception {
StringBuilder sql = new StringBuilder(
"SELECT bp.Name as VendorName, ")
.append("pc.Name as Category, ")
.append("SUM(ol.LineNetAmt) as TotalSpend, ")
.append("COUNT(DISTINCT o.C_Order_ID) as OrderCount, ")
.append("SUM(ol.QtyOrdered) as TotalQty ")
.append("FROM C_Order o ")
.append("JOIN C_OrderLine ol ON o.C_Order_ID = ol.C_Order_ID ")
.append("JOIN C_BPartner bp ON o.C_BPartner_ID = bp.C_BPartner_ID ")
.append("JOIN M_Product p ON ol.M_Product_ID = p.M_Product_ID ")
.append("JOIN M_Product_Category pc ON p.M_Product_Category_ID = pc.M_Product_Category_ID ")
.append("WHERE o.IsSOTrx = 'N' ") // Purchase orders only
.append("AND o.DocStatus IN ('CO','CL') ");
ArrayList<Object> params = new ArrayList<>();
if (p_C_BPartner_ID > 0) {
sql.append("AND o.C_BPartner_ID = ? ");
params.add(p_C_BPartner_ID);
}
if (p_DateFrom != null) {
sql.append("AND o.DateOrdered >= ? ");
params.add(p_DateFrom);
}
if (p_DateTo != null) {
sql.append("AND o.DateOrdered <= ? ");
params.add(p_DateTo);
}
if (p_M_Product_Category_ID > 0) {
sql.append("AND p.M_Product_Category_ID = ? ");
params.add(p_M_Product_Category_ID);
}
sql.append("GROUP BY bp.Name, pc.Name ")
.append("ORDER BY TotalSpend DESC");
int count = 0;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
pstmt = DB.prepareStatement(sql.toString(), get_TrxName());
DB.setParameters(pstmt, params);
rs = pstmt.executeQuery();
while (rs.next()) {
String vendor = rs.getString("VendorName");
String category = rs.getString("Category");
BigDecimal spend = rs.getBigDecimal("TotalSpend");
int orders = rs.getInt("OrderCount");
addLog(String.format("%s | %s | Spend: %s | Orders: %d",
vendor, category, spend, orders));
count++;
}
} finally {
DB.close(rs, pstmt);
}
return "@Processed@ " + count + " vendor-category combinations";
}
}
7. Procurement Reporting SQL
The following SQL queries use actual iDempiere table and column names and can be adapted for custom reports, dashboard views, or external BI tools.
7.1 Purchase Order Status Dashboard
// Open PO Aging Report
String sql =
"SELECT bp.Name as Vendor, " +
" o.DocumentNo, o.DateOrdered, o.DatePromised, " +
" o.GrandTotal, o.DocStatus, " +
" CURRENT_DATE - o.DateOrdered as DaysOpen, " +
" CASE WHEN o.DatePromised < CURRENT_DATE THEN 'OVERDUE' " +
" ELSE 'ON TIME' END as Status " +
"FROM C_Order o " +
"JOIN C_BPartner bp ON o.C_BPartner_ID = bp.C_BPartner_ID " +
"WHERE o.IsSOTrx = 'N' " +
" AND o.DocStatus = 'CO' " + // Completed but not closed
" AND EXISTS (SELECT 1 FROM C_OrderLine ol " +
" WHERE ol.C_Order_ID = o.C_Order_ID " +
" AND ol.QtyOrdered > ol.QtyDelivered) " + // Has undelivered lines
"ORDER BY o.DatePromised ASC";
7.2 Vendor Spend Analysis
// Top vendors by spend in a date range
String sql =
"SELECT bp.Name, bp.Value as VendorCode, " +
" COUNT(DISTINCT o.C_Order_ID) as POCount, " +
" SUM(ol.LineNetAmt) as TotalSpend, " +
" AVG(ol.PriceActual) as AvgPrice, " +
" COUNT(DISTINCT ol.M_Product_ID) as ProductCount " +
"FROM C_OrderLine ol " +
"JOIN C_Order o ON ol.C_Order_ID = o.C_Order_ID " +
"JOIN C_BPartner bp ON o.C_BPartner_ID = bp.C_BPartner_ID " +
"WHERE o.IsSOTrx = 'N' " +
" AND o.DocStatus IN ('CO','CL') " +
" AND o.DateOrdered BETWEEN ? AND ? " +
"GROUP BY bp.Name, bp.Value " +
"ORDER BY TotalSpend DESC";
7.3 Stock Level Monitoring
// Products below reorder point
String sql =
"SELECT p.Value as ProductCode, p.Name as ProductName, " +
" r.Level_Min, r.Level_Max, r.ReplenishType, " +
" COALESCE(bomQtyOnHand(p.M_Product_ID, r.M_Warehouse_ID, 0), 0) as QtyOnHand, " +
" COALESCE((SELECT SUM(ol.QtyOrdered - ol.QtyDelivered) " +
" FROM C_OrderLine ol JOIN C_Order o ON ol.C_Order_ID = o.C_Order_ID " +
" WHERE ol.M_Product_ID = p.M_Product_ID " +
" AND o.IsSOTrx = 'N' AND o.DocStatus = 'CO'), 0) as QtyOnOrder, " +
" r.Level_Min - COALESCE(bomQtyOnHand(p.M_Product_ID, r.M_Warehouse_ID, 0), 0) as Shortage " +
"FROM M_Replenish r " +
"JOIN M_Product p ON r.M_Product_ID = p.M_Product_ID " +
"WHERE r.ReplenishType <> '0' " +
" AND r.IsActive = 'Y' " +
" AND r.M_Warehouse_ID = ? " +
" AND COALESCE(bomQtyOnHand(p.M_Product_ID, r.M_Warehouse_ID, 0), 0) < r.Level_Min " +
"ORDER BY Shortage DESC";
7.4 Receipt Performance
// Receipt turnaround time (PO Date to Receipt Date)
String sql =
"SELECT bp.Name as Vendor, " +
" o.DocumentNo as PO_Number, " +
" o.DateOrdered, o.DatePromised, " +
" io.MovementDate as ReceiptDate, " +
" io.MovementDate - o.DateOrdered as TotalLeadDays, " +
" io.MovementDate - o.DatePromised as DaysLateEarly " +
"FROM M_InOut io " +
"JOIN C_Order o ON io.C_Order_ID = o.C_Order_ID " +
"JOIN C_BPartner bp ON o.C_BPartner_ID = bp.C_BPartner_ID " +
"WHERE io.MovementType = 'V+' " + // Vendor Receipts
" AND io.DocStatus IN ('CO','CL') " +
" AND io.MovementDate BETWEEN ? AND ? " +
"ORDER BY bp.Name, io.MovementDate";
8. Automation Patterns
8.1 Scheduled Process Execution
iDempiere supports automated process execution through the AD_Scheduler table. The replenishment report can be scheduled to run daily or weekly:
// Scheduler configuration (via Application Dictionary):
// AD_Scheduler record:
// AD_Process_ID → ReplenishReport process
// ScheduleType → "F" (Frequency) or "C" (Cron)
// FrequencyType → "D" (Daily), "W" (Weekly), "M" (Monthly)
// Frequency → 1 (every 1 day/week/month)
// AD_Schedule_ID → links to parameter values
// Parameters stored in AD_Scheduler_Para:
// M_Warehouse_ID → target warehouse
// ReplenishmentCreate → "POO" (auto-create POs)
// C_DocType_ID → PO document type
8.2 ModelValidator for Event-Driven Procurement
A ModelValidator can trigger procurement actions automatically in response to business events:
public class AutoProcurementValidator implements ModelValidator {
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();
// Listen for inventory changes
engine.addDocValidate(MInOut.Table_Name, this);
}
@Override
public int getAD_Client_ID() {
return m_AD_Client_ID;
}
@Override
public String docValidate(PO po, int timing) {
if (timing == TIMING_AFTER_COMPLETE && po instanceof MInOut) {
MInOut inout = (MInOut) po;
// Only for customer shipments (reduces stock)
if (inout.isSOTrx() &&
"C-".equals(inout.getMovementType())) {
checkReplenishment(inout);
}
}
return null;
}
private void checkReplenishment(MInOut shipment) {
for (MInOutLine line : shipment.getLines()) {
int productId = line.getM_Product_ID();
int warehouseId = shipment.getM_Warehouse_ID();
// Check if stock fell below reorder point
MReplenish rule = MReplenish.getForProduct(
shipment.getCtx(), productId, warehouseId,
shipment.get_TrxName());
if (rule != null &&
!X_M_Replenish.REPLENISHTYPE_Manual.equals(
rule.getReplenishType())) {
BigDecimal qtyOnHand = MStorageOnHand.getQtyOnHand(
productId, warehouseId, 0,
shipment.get_TrxName());
if (qtyOnHand.compareTo(rule.getLevel_Min()) < 0) {
// Stock below minimum — log alert
MNote note = new MNote(shipment.getCtx(),
"Reorder Alert", 0, shipment.get_TrxName());
note.setTextMsg("Product " + productId +
" below reorder point. QtyOnHand: " + qtyOnHand +
", Level_Min: " + rule.getLevel_Min());
note.saveEx();
}
}
}
}
@Override
public String modelChange(PO po, int type) { return null; }
@Override
public String login(int AD_Org_ID, int AD_Role_ID, int AD_User_ID) {
return null;
}
}
9. Complete Code Examples
9.1 Stock Level Monitoring Process
/**
* Process to check stock levels and generate alerts for
* products below their reorder points.
*/
public class StockLevelMonitor extends SvrProcess {
private int p_M_Warehouse_ID = 0;
@Override
protected void prepare() {
ProcessInfoParameter[] params = getParameter();
for (ProcessInfoParameter para : params) {
if ("M_Warehouse_ID".equals(para.getParameterName()))
p_M_Warehouse_ID = para.getParameterAsInt();
}
if (p_M_Warehouse_ID == 0)
p_M_Warehouse_ID = getParameterAsInt("M_Warehouse_ID");
}
@Override
protected String doIt() throws Exception {
if (p_M_Warehouse_ID == 0)
throw new AdempiereException("@M_Warehouse_ID@ @NotFound@");
String sql =
"SELECT r.M_Product_ID, r.Level_Min, r.Level_Max, " +
" r.ReplenishType, r.C_BPartner_ID " +
"FROM M_Replenish r " +
"WHERE r.M_Warehouse_ID = ? " +
" AND r.ReplenishType <> '0' " +
" AND r.IsActive = 'Y'";
int alertCount = 0;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
pstmt = DB.prepareStatement(sql, get_TrxName());
pstmt.setInt(1, p_M_Warehouse_ID);
rs = pstmt.executeQuery();
while (rs.next()) {
int productId = rs.getInt("M_Product_ID");
BigDecimal levelMin = rs.getBigDecimal("Level_Min");
BigDecimal qtyOnHand = MStorageOnHand.getQtyOnHand(
productId, p_M_Warehouse_ID, 0, get_TrxName());
if (qtyOnHand == null)
qtyOnHand = Env.ZERO;
if (qtyOnHand.compareTo(levelMin) < 0) {
MProduct product = MProduct.get(getCtx(), productId);
BigDecimal shortage = levelMin.subtract(qtyOnHand);
addLog(String.format(
"ALERT: %s [%s] - OnHand: %s, Min: %s, Short: %s",
product.getName(), product.getValue(),
qtyOnHand, levelMin, shortage));
alertCount++;
}
}
} finally {
DB.close(rs, pstmt);
}
return alertCount > 0
? "@Alert@ " + alertCount + " products below reorder point"
: "All stock levels OK";
}
}
9.2 Automated PO Creation from Replenishment
// Programmatic execution of ReplenishReport to create POs:
MPInstance instance = new MPInstance(ctx, processId, 0);
instance.saveEx();
// Set parameters
MPInstancePara para1 = new MPInstancePara(instance, 10);
para1.setParameterName("M_Warehouse_ID");
para1.setP_Number(warehouseId);
para1.saveEx();
MPInstancePara para2 = new MPInstancePara(instance, 20);
para2.setParameterName("ReplenishmentCreate");
para2.setP_String("POO"); // Create Purchase Orders
para2.saveEx();
// Execute
ProcessInfo pi = new ProcessInfo("Replenishment", processId);
pi.setAD_PInstance_ID(instance.getAD_PInstance_ID());
pi.setAD_Client_ID(Env.getAD_Client_ID(ctx));
ServerProcessCtl.process(pi, null);
if (pi.isError()) {
log.severe("Replenishment failed: " + pi.getSummary());
} else {
log.info("Replenishment completed: " + pi.getSummary());
}
10. Common Pitfalls and Best Practices
| Pitfall | Solution |
|---|---|
No IsCurrentVendor set on M_Product_PO |
ReplenishReport requires IsCurrentVendor = 'Y' to find the vendor. Ensure every purchased product has a current vendor. |
Missing M_Replenish records |
Products without replenishment rules are ignored. Create rules for all purchased products. |
Level_Min = Level_Max |
For "Reorder Below Min", this means zero safety stock. Set Level_Max higher than Level_Min for buffer. |
Order_Pack causing over-ordering |
Understand that pack rounding always rounds UP. If Order_Pack = 100 and you need 1 unit, you'll order 100. |
| Negative inventory after shipment | Enable IsDisallowNegativeInv on the warehouse. The MStorageOnHand.beforeSave() will block over-shipments. |
| Stale replenishment data | Run ReplenishReport frequently (daily or more). Stale data leads to stockouts or double-ordering. |
| Custom replenishment class not found | When using ReplenishType = "9", ensure the custom class is properly deployed in an OSGi bundle and registered. |
11. Summary
- MReplenish defines per-product, per-warehouse rules with four types: Manual (0), Reorder Below Min (1), Maintain Max (2), Custom (9). Key fields:
Level_Min,Level_Max,M_WarehouseSource_ID,C_BPartner_ID. - MStorageOnHand tracks on-hand inventory at the product/locator/ASI/date level. The
add()method chain creates or updates records viagetOrCreate(). Query methods support warehouse, locator, reservation, and shipping granularities. - The
beforeSave()negative inventory check onMStorageOnHandenforces the warehouse-levelIsDisallowNegativeInvflag, preventing inventory from going below zero. - ReplenishReport is the main engine: populates
T_Replenish, calculates quantities, appliesOrder_Min/Order_Packconstraints, and generates POs (POO), Requisitions (POR), Movements (MMM), or Distribution Orders (DOO). - ReplenishReportProduction extends the standard report with manufacturing order generation and BOM-based replenishment.
- The SvrProcess pattern (
prepare()/doIt()) is the foundation for all custom processes. UsegetParameterAsInt(),addLog(), andaddBufferLog()for parameter access and user feedback. - Procurement SQL queries use standard iDempiere tables:
C_Order/C_OrderLinefor POs,M_InOut/M_InOutLinefor receipts,M_Product_POfor vendor products,M_Replenish/M_StorageOnHandfor inventory. - AD_Scheduler enables automated ReplenishReport execution on a cron/frequency basis for hands-off procurement.
- ModelValidator approach enables event-driven procurement: trigger reorder alerts or automatic PO creation when stock falls below minimums after customer shipments.
- Always set
IsCurrentVendor = 'Y'on at least oneM_Product_POrecord per product — the ReplenishReport requires this to generate orders.