Complete procurement lifecycle — MR → PR → RFQ → PO → GRN → Invoice → Payment
InfraTraq implements a full-cycle procurement workflow covering the complete MR → PR → RFQ → PO → GRN → Invoice → Payment pipeline. The system manages 22 procurement tables, tightly connected to vendor management, receiving, inventory, and finance modules — ensuring end-to-end traceability from material indent to final payment.
| Status | Description | Allowed Actions | Next States |
|---|---|---|---|
| Draft | MR/PR/PO created but not yet submitted for approval | Edit, Submit, Delete | Pending Approval |
| Pending Approval | Awaiting manager/budget-holder approval | Approve, Reject, Return | Approved, Rejected |
| Approved | MR/PR/PO approved and ready for next stage | Convert to PR, Create RFQ, Issue PO | Open, In Progress |
| Open | RFQ open for vendor quotations / PO issued to vendor | Receive Quotes, Record GRN, Amend | Partial, Closed |
| Partial | PO partially delivered or partially invoiced | Record GRN, Invoice Match, Short-Close | Closed |
| Closed | Fully delivered, invoiced, and paid | View, Audit | — |
| Short-Closed | PO closed before full delivery; balance released | View, Audit | — |
| Cancelled | MR/PR/PO cancelled; commitment released | View, Audit | — |
| Rejected | Approval denied; returned to originator | Edit, Resubmit, Delete | Draft |
| Hold | Invoice held pending 3-way match resolution | Resolve Variance, Re-match | Matched, Cancelled |
| Matched | 3-way match passed; invoice auto-approved | Schedule Payment | Paid |
mr_id — PK, material indent raised by site team against project activityproject_id — FK → project.projectrequested_by — FK → hr.employeemr_number, mr_date, required_datepriority — Urgent / Normal / Low (drives SLA timelines)status — draft, pending_approval, approved, rejected, cancelledpr_id — PK, consolidated requisition grouping one or more MRs for buyer actionproject_id — FK → project.projectmr_id — FK → procurement.material_requisitionpr_number, buyer_id — FK → hr.employeestatus — open, in_progress, closed, cancelledrfq_id — PK, request for quotation sent to shortlisted vendorspr_id — FK → procurement.purchase_requisitionproject_id — FK → project.projectrfq_number, closing_datestatus — open, closed, cancelledpo_id — PK, formal order placed with selected vendor after approvalpr_id — FK → procurement.purchase_requisitionvendor_id — FK → vendor.vendorproject_id — FK → project.projectpo_number, total_amountstatus — draft, approved, open, partial, closed, short_closed, cancelledid — PK, individual line items within a purchase orderpo_id — FK → procurement.purchase_ordermaterial_id — FK → master_data.materialordered_qty, unit_rate, amountstatus — open, partial, closedgrn_id — PK, goods received note recorded at store on material deliverypo_id — FK → procurement.purchase_orderstore_id — FK → inventory.storegrn_number, total_valuestatus — pending_qc, accepted, rejected, partialmatch_id — PK, verification record matching PO, GRN, and vendor invoicepo_id — FK → procurement.purchase_ordergrn_id — FK → receiving.grninvoice_id — FK → accounts_payable.ap_invoicematch_status — matched, hold, resolvedrc_id — PK, pre-negotiated rate agreements with vendors for recurring materialsvendor_id — FK → vendor.vendorrc_number, valid_from, valid_tostatus — active, expired, suspendedSite engineer raises an MR in procurement.material_requisition specifying the project, activity, required materials, quantities, and required-by date. The system validates against BOM requirements and checks current stock levels in inventory.stock_ledger before submission. Priority levels (Urgent / Normal / Low) drive SLA timelines.
Approved MRs are consolidated into a procurement.purchase_requisition (PR). Multiple MRs for the same material category or vendor can be grouped into a single PR to leverage bulk pricing. The PR is assigned to a buyer (buyer_id) who manages the sourcing process.
For non-rate-contract items, the buyer creates an procurement.rfq and invites shortlisted vendors. The RFQ includes material specifications, quantities, delivery schedule, and closing date. Minimum 3 vendors must be invited per company policy. Vendor selection draws from the approved vendor list.
Received quotations are evaluated using a weighted scoring model (price 60%, delivery 20%, quality 10%, terms 10%). The system generates an auto-ranked comparative statement. Technical and commercial evaluations can be split for high-value procurements. L1 (lowest) vendor is recommended unless overridden with justification.
PO is created in procurement.purchase_order with line items in procurement.po_line. For rate-contract items, the PO is auto-generated from procurement.rate_contract without the RFQ stage. Budget availability is checked before approval. On approval, a financial commitment is created in finance.commitment.
When material arrives, the store team records a GRN in receiving.grn. Quantities are verified against PO line items (tolerance ±5%). Quality inspection is performed per QMS standards. Accepted quantities update inventory.stock_ledger and trigger an accrual journal entry. Rejected quantities initiate a vendor return process.
The receiving.three_way_match table records the verification of PO quantities/rates against GRN received quantities against invoice billed quantities/rates. Tolerance is ±5% on quantity and ±2% on price. Matched records auto-approve the invoice; mismatches are held for manual resolution.
Matched invoices are booked in accounts_payable.ap_invoice. Accrual provisional entries are reversed and final journal entries posted. GST input credit is captured, TDS is auto-deducted per vendor category. Payments are scheduled per vendor payment terms (Net 30/45/60) and processed via accounts_payable.payment_voucher.
class ProcurementService { /** Step 1: Create Material Requisition — validates stock & BOM */ async createMR(projectId, items) { for (const item of items) { const stock = await StockLedger.getBalance(item.material_id, projectId); const bomReq = await BOM.getRequirement(projectId, item.material_id); if (stock.available >= item.qty) throw new Error('Sufficient stock exists — issue from store'); item.net_qty = item.qty - stock.available; } const mr = await MaterialRequisition.create({ project_id: projectId, mr_number: await Sequence.next('MR'), mr_date: new Date(), status: 'draft', items }); return mr; } /** Step 2: Convert approved MR(s) into Purchase Requisition */ async convertMRtoPR(mrId) { const mr = await MaterialRequisition.findById(mrId); if (mr.status !== 'approved') throw new Error('MR must be approved'); // Group MR lines by vendor preference / material category const groups = groupBy(mr.items, 'material_category'); const prs = []; for (const [category, lines] of Object.entries(groups)) { const pr = await PurchaseRequisition.create({ project_id: mr.project_id, mr_id: mrId, pr_number: await Sequence.next('PR'), buyer_id: await BuyerAssignment.resolve(category), status: 'open', items: lines }); prs.push(pr); } return prs; } /** Step 3: Create RFQ and send to shortlisted vendors */ async createRFQ(prId, vendorIds) { if (vendorIds.length < 3) throw new Error('Minimum 3 vendors required for RFQ'); const pr = await PurchaseRequisition.findById(prId); const rfq = await RFQ.create({ pr_id: prId, project_id: pr.project_id, rfq_number: await Sequence.next('RFQ'), closing_date: addDays(new Date(), 7), status: 'open', vendor_ids: vendorIds }); await NotificationService.sendRFQInvitations(rfq, vendorIds); return rfq; } /** Step 4: Evaluate quotations and generate comparative statement */ async evaluateQuotations(rfqId) { const quotes = await Quotation.findByRFQ(rfqId); const scored = quotes.map(q => ({ vendor_id: q.vendor_id, price_score: normalize(q.total, quotes, 'total') * 0.6, delivery_score: normalize(q.lead_days, quotes, 'lead_days') * 0.2, quality_score: q.vendor_rating * 0.1, terms_score: normalize(q.credit_days, quotes, 'credit_days') * 0.1, })); scored.forEach(s => s.total_score = s.price_score + s.delivery_score + s.quality_score + s.terms_score); scored.sort((a, b) => b.total_score - a.total_score); return { comparative_statement: scored, recommended: scored[0] }; } /** Step 5: Create Purchase Order — checks budget, creates commitment */ async createPO(prId, vendorId) { const pr = await PurchaseRequisition.findById(prId); const poTotal = pr.items.reduce((s, i) => s + i.qty * i.unit_rate, 0); // Budget availability check await CommitmentService.checkBudgetAvailability( pr.project_id, pr.cost_code_id, poTotal ); // Check for duplicate active PO for same PR const existing = await PurchaseOrder.findOne({ pr_id: prId, vendor_id: vendorId, status: ['draft', 'approved'] }); if (existing) throw new Error('Active PO already exists for this PR + vendor'); const po = await PurchaseOrder.create({ pr_id: prId, vendor_id: vendorId, project_id: pr.project_id, po_number: await Sequence.next('PO'), total_amount: poTotal, status: 'draft' }); // On approval → create financial commitment await CommitmentService.createCommitment('PO', po.po_id); return po; } /** Step 6: Process GRN — quality check, stock update */ async processGRN(poId, receivedItems) { const po = await PurchaseOrder.findById(poId); for (const item of receivedItems) { const poLine = await POLine.findOne({ po_id: poId, material_id: item.material_id }); const variance = Math.abs(item.received_qty - poLine.ordered_qty) / poLine.ordered_qty; if (variance > 0.05) throw new Error('GRN qty exceeds ±5% tolerance'); // Quality inspection item.qc_status = await QualityService.inspect(item); } const grn = await GRN.create({ po_id: poId, store_id: po.delivery_store_id, grn_number: await Sequence.next('GRN'), total_value: receivedItems.reduce((s, i) => s + i.received_qty * i.unit_rate, 0), status: 'accepted', items: receivedItems.filter(i => i.qc_status === 'pass') }); // Update inventory stock ledger await InventoryService.updateStock(grn); // Post accrual entry await CommitmentService.createAccrual(po.commitment_id, grn.grn_id, grn.total_value); return grn; } /** Step 7: Three-way match — validates qty and price within tolerance */ async threeWayMatch(poId, grnId, invoiceId) { const po = await PurchaseOrder.findById(poId); const grn = await GRN.findById(grnId); const inv = await APInvoice.findById(invoiceId); const qtyMatch = Math.abs(grn.total_qty - inv.total_qty) / grn.total_qty <= 0.05; const rateMatch = Math.abs(po.unit_rate - inv.unit_rate) / po.unit_rate <= 0.02; const match_status = (qtyMatch && rateMatch) ? 'matched' : 'hold'; const match = await ThreeWayMatch.create({ po_id: poId, grn_id: grnId, invoice_id: invoiceId, match_status }); if (match_status === 'matched') { await APInvoice.approve(invoiceId); await CommitmentService.onInvoiceBooked(po.commitment_id, inv.total_amount); } return match; } }
| Rule | Condition | Action |
|---|---|---|
| Budget Availability Check | PO total > available budget for cost code | Block PO creation, notify finance manager |
| Minimum Quotes Required | RFQ has fewer than 3 vendor quotations | Prevent PO creation, extend RFQ deadline |
| Rate Contract Validity | Rate contract expired or quantity ceiling exceeded | Block auto-PO, route to manual RFQ process |
| GRN Quantity Tolerance | Received qty deviates > ±5% from PO line qty | Hold GRN for store manager approval |
| Price Variance Threshold | Invoice unit rate deviates > ±2% from PO rate | Hold invoice, escalate to procurement head |
| Duplicate PO Prevention | Active PO already exists for same PR + vendor | Block creation, show existing PO reference |
| MR Stock Check | Sufficient stock exists in project store | Reject MR, suggest store issue instead |
| Vendor Blacklist Check | Selected vendor is on blacklist or suspended | Block PO/RFQ, notify compliance team |
| Event | Source Table | Auto Action |
|---|---|---|
| MR Approved | procurement.material_requisition | Auto-create Purchase Requisition (PR) grouped by category |
| PO Approved | procurement.purchase_order | Create financial commitment in finance.commitment |
| GRN Accepted | receiving.grn | Update inventory.stock_ledger + post accrual journal entry |
| 3-Way Match Pass | receiving.three_way_match | Auto-approve AP invoice for payment scheduling |
| Invoice Approved | accounts_payable.ap_invoice | Schedule payment per vendor payment terms (Net 30/45/60) |
| Rate Contract Expiry (T-30) | procurement.rate_contract | Alert procurement team to initiate renewal RFQ |
| Reorder Point Hit | inventory.stock_ledger | Auto-generate MR for materials below reorder level |
| PO Short-Close | procurement.purchase_order | Release uncommitted balance back to available budget |